From 881056eb6140d17ebef411f78be262e91d9a96bc Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 1 May 2026 17:49:50 +0200 Subject: [PATCH] chore: added more @spec --- CODESMELL.md | 4 +- lib/bds/ai/catalog_provider.ex | 4 +- lib/bds/ai/chat_conversation.ex | 4 +- lib/bds/ai/chat_message.ex | 26 +- lib/bds/ai/chat_tools.ex | 95 +- lib/bds/ai/http_client.ex | 11 +- lib/bds/ai/model.ex | 48 +- lib/bds/ai/openai_compatible_runtime.ex | 36 +- lib/bds/ai/runtime.ex | 7 +- lib/bds/ai/secret_backend.ex | 10 +- lib/bds/ai/settings_store.ex | 4 +- lib/bds/cli_sync.ex | 18 +- lib/bds/cli_sync/notification.ex | 4 +- lib/bds/cli_sync/watcher.ex | 12 +- lib/bds/desktop/automation.ex | 21 +- lib/bds/desktop/endpoint.ex | 18 +- lib/bds/desktop/main_window.ex | 26 +- lib/bds/desktop/media_controller.ex | 6 +- lib/bds/desktop/overlay.ex | 6 +- lib/bds/desktop/router.ex | 20 +- lib/bds/desktop/shell_data.ex | 95 +- lib/bds/desktop/shell_live/chat_editor.ex | 31 +- .../shell_live/chat_editor/message_build.ex | 1 + .../shell_live/chat_editor/model_selection.ex | 4 + .../shell_live/chat_editor/tool_surfaces.ex | 42 +- .../shell_live/chat_editor/tool_tracking.ex | 6 + .../desktop/shell_live/code_entity_editor.ex | 14 + lib/bds/desktop/shell_live/import_editor.ex | 16 +- .../import_editor/analysis_state.ex | 105 +- .../import_editor/conflict_resolution.ex | 20 +- .../import_editor/progress_tracking.ex | 95 +- .../import_editor/taxonomy_editing.ex | 17 + lib/bds/desktop/shell_live/layout.ex | 1 + lib/bds/desktop/shell_live/media_editor.ex | 254 +++- lib/bds/desktop/shell_live/menu_editor.ex | 36 +- .../menu_editor/draft_management.ex | 24 +- .../shell_live/menu_editor/page_category.ex | 13 +- .../desktop/shell_live/menu_editor/state.ex | 21 +- .../shell_live/menu_editor/tree_ops.ex | 104 +- .../shell_live/menu_editor/tree_predicates.ex | 23 +- lib/bds/desktop/shell_live/misc_editor.ex | 21 + .../desktop/shell_live/overlay_components.ex | 124 +- lib/bds/desktop/shell_live/panel_renderer.ex | 29 +- lib/bds/desktop/shell_live/post_editor.ex | 288 +++- .../post_editor/draft_management.ex | 91 +- .../shell_live/post_editor/list_values.ex | 16 +- .../shell_live/post_editor/persistence.ex | 17 +- .../shell_live/post_editor/post_metadata.ex | 53 +- lib/bds/desktop/shell_live/session_util.ex | 4 +- lib/bds/desktop/shell_live/settings_editor.ex | 21 +- .../shell_live/settings_editor/ai_settings.ex | 18 +- .../settings_editor/editor_settings.ex | 8 +- .../settings_editor/managed_categories.ex | 11 +- .../shell_live/settings_editor/mcp_config.ex | 2 + .../settings_editor/project_settings.ex | 5 + .../settings_editor/publishing_settings.ex | 4 + .../settings_editor/style_editor.ex | 9 +- lib/bds/desktop/shell_live/sidebar_create.ex | 62 +- lib/bds/desktop/shell_live/sidebar_state.ex | 38 +- lib/bds/desktop/shell_live/tags_editor.ex | 99 +- .../desktop/shell_live/task_localization.ex | 5 +- lib/bds/desktop/shell_live/titlebar_menu.ex | 12 +- lib/bds/embeddings/backends/in_app.ex | 2 +- lib/bds/frontmatter.ex | 4 +- lib/bds/generation.ex | 162 +- lib/bds/generation/data.ex | 161 +- lib/bds/generation/outputs.ex | 124 +- lib/bds/generation/pagefind.ex | 7 +- lib/bds/generation/paths.ex | 39 +- lib/bds/generation/renderers.ex | 13 +- lib/bds/generation/sitemap.ex | 69 +- lib/bds/generation/validation.ex | 125 +- lib/bds/git.ex | 82 +- lib/bds/import_analysis.ex | 113 +- lib/bds/import_definitions.ex | 12 +- .../import_definitions/import_definition.ex | 11 +- lib/bds/import_execution.ex | 180 ++- lib/bds/maintenance.ex | 10 +- lib/bds/maintenance/diff_computation.ex | 5 +- lib/bds/maintenance/diff_reports.ex | 30 +- lib/bds/mcp/agent_config.ex | 17 +- lib/bds/mcp/proposal.ex | 10 +- lib/bds/mcp/proposal_store.ex | 10 +- lib/bds/mcp/server.ex | 30 +- lib/bds/mcp/stdio.ex | 74 +- lib/bds/mcp/tools.ex | 10 +- lib/bds/media/rebuilder.ex | 10 +- lib/bds/media/sidecars.ex | 7 +- lib/bds/media/thumbnails.ex | 4 +- lib/bds/persistence.ex | 4 +- lib/bds/post_links.ex | 35 +- lib/bds/posts/auto_translation.ex | 3 +- lib/bds/posts/link.ex | 11 +- lib/bds/posts/translation_validation.ex | 49 +- lib/bds/preview.ex | 28 +- lib/bds/projects.ex | 10 +- lib/bds/rebuild.ex | 6 +- lib/bds/release_packaging.ex | 19 +- lib/bds/rendering.ex | 24 +- lib/bds/rendering/metadata.ex | 16 +- lib/bds/scripting.ex | 15 +- lib/bds/scripting/api_docs.ex | 1346 +++++++++++++++-- lib/bds/scripting/capabilities.ex | 125 +- lib/bds/scripting/capabilities/app_shell.ex | 26 +- lib/bds/scripting/capabilities/bridges.ex | 27 +- lib/bds/scripting/capabilities/crud.ex | 34 +- lib/bds/scripting/capabilities/media.ex | 81 +- lib/bds/scripting/capabilities/posts.ex | 44 +- lib/bds/scripting/capabilities/projects.ex | 14 +- lib/bds/scripting/capabilities/util.ex | 8 +- lib/bds/scripting/lua.ex | 8 +- lib/bds/scripts.ex | 12 + lib/bds/settings.ex | 6 +- lib/bds/tags.ex | 24 +- lib/bds/tags/tag.ex | 12 + lib/bds/templates.ex | 13 + lib/bds/ui/dashboard.ex | 22 +- lib/bds/ui/registry.ex | 92 +- lib/bds/ui/workbench.ex | 1 + lib/bds/wxr_parser.ex | 10 +- lib/mix/tasks/bds.package.ex | 6 +- test/bds/ai_test.exs | 155 +- test/bds/cli_sync_test.exs | 7 +- test/bds/desktop/automation_test.exs | 7 +- test/bds/desktop/import_shell_live_test.exs | 23 +- test/bds/desktop/main_window_test.exs | 12 +- test/bds/desktop/overlay_test.exs | 86 +- test/bds/desktop/shell_commands_test.exs | 134 +- test/bds/desktop/shell_live_test.exs | 227 ++- test/bds/desktop_test.exs | 23 +- test/bds/embeddings_test.exs | 17 +- test/bds/generation_test.exs | 77 +- test/bds/git_test.exs | 117 +- test/bds/import_analysis_test.exs | 87 +- test/bds/import_definitions_test.exs | 26 +- test/bds/import_execution_test.exs | 52 +- test/bds/maintenance_test.exs | 491 +++--- test/bds/mcp_agent_config_test.exs | 26 +- test/bds/mcp_server_test.exs | 14 +- test/bds/mcp_test.exs | 27 +- test/bds/menu_test.exs | 9 +- test/bds/post_links_test.exs | 11 +- test/bds/post_translations_test.exs | 19 +- test/bds/posts_test.exs | 29 +- test/bds/preview_test.exs | 37 +- test/bds/projects_test.exs | 12 +- .../bds/real_blog_rebuild_diagnostic_test.exs | 4 +- test/bds/release_packaging_test.exs | 14 +- test/bds/rendering_test.exs | 18 +- test/bds/repo/bootstrap_test.exs | 12 +- test/bds/scripting/api_test.exs | 105 +- test/bds/spec_coverage_test.exs | 82 + test/bds/ui/shell_test.exs | 64 +- test/bds/ui/sidebar_test.exs | 114 +- test/bds/ui/workbench_test.exs | 14 +- test/bds/wxr_parser_test.exs | 24 +- test/test_helper.exs | 3 +- 157 files changed, 6223 insertions(+), 1647 deletions(-) create mode 100644 test/bds/spec_coverage_test.exs diff --git a/CODESMELL.md b/CODESMELL.md index 980fec8..6877966 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -107,7 +107,7 @@ _None._ All modules previously on the queue have been split; refresh the queue i ## 10. Missing `@spec` -**Status:** ✅ done for core contexts (2026-04-30). Open for LiveView editor modules and the smaller contexts (`Tags`, `Templates`, `Scripts`, `PostLinks`). +**Status:** ✅ done (2026-05-01). Core contexts, LiveView editor modules, and the smaller contexts (`Tags`, `Templates`, `Scripts`, `PostLinks`) now have public `@spec` coverage. `BDS.SpecCoverageTest` scans the Section 10 module set and fails when a public function/arity lacks a matching spec. **Convention reminder:** Ecto schemas need explicit `@type t`; use `term()` for associations; use `@typedoc` + named types for repeated map shapes; for attrs maps use `%{optional(atom()) => term(), optional(String.t()) => term()}`. @@ -155,6 +155,8 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search` ### 2026-05-01 +- **Missing `@spec`**: closed Section 10 by adding public specs to the remaining smaller contexts (`BDS.Tags`, `BDS.Templates`, `BDS.Scripts`, `BDS.PostLinks`) and LiveView editor modules under `BDS.Desktop.ShellLive.*Editor`. Added `BDS.Tags.Tag.t/0` to match the existing schema typing convention and introduced `BDS.SpecCoverageTest`, which scans the Section 10 module set for public function/arity entries without matching specs. + - **`BDS.Tasks` memory growth**: added configurable TTL eviction for terminal tasks in the `BDS.Tasks` GenServer (`:finished_task_ttl_ms`, default 1 h). Finished tasks are pruned by delayed GenServer cleanup and on task read calls; active tasks are preserved. Added regression coverage that completes a task with a tiny TTL and verifies it disappears without `clear_finished/0` while a running task remains. Section 13 is closed. - **Atom/string key duality**: added `BDS.MapUtils.attr/3` and a regression test that scans `lib/**/*.ex` and `lib/**/*.heex` for same-name atom/string `Map.get` fallback reads. Replaced same-name atom/string boundary reads across AI attrs, rendering assigns, pagination/archive contexts, UI command/filter params, metadata category settings, metadata-diff repair payloads, CLI sync payloads, chat tool call normalization, and misc editor duplicate/metadata-diff payload rendering. Remaining mixed-key scan hits are intentionally different-key fallbacks (for example camelCase/snake_case JSON compatibility) or atom-only/string-only boundaries. Section 12 is closed. diff --git a/lib/bds/ai/catalog_provider.ex b/lib/bds/ai/catalog_provider.ex index 12f44e9..e7dd359 100644 --- a/lib/bds/ai/catalog_provider.ex +++ b/lib/bds/ai/catalog_provider.ex @@ -17,7 +17,9 @@ defmodule BDS.AI.CatalogProvider do def changeset(provider, attrs) do provider - |> cast(attrs, [:id, :name, :env_keys, :package_ref, :api_url, :doc_url, :updated_at], empty_values: [nil]) + |> cast(attrs, [:id, :name, :env_keys, :package_ref, :api_url, :doc_url, :updated_at], + empty_values: [nil] + ) |> validate_required([:id, :name, :updated_at]) end end diff --git a/lib/bds/ai/chat_conversation.ex b/lib/bds/ai/chat_conversation.ex index 610ecf8..9480fbe 100644 --- a/lib/bds/ai/chat_conversation.ex +++ b/lib/bds/ai/chat_conversation.ex @@ -25,7 +25,9 @@ defmodule BDS.AI.ChatConversation do def changeset(conversation, attrs) do conversation - |> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at], empty_values: [nil]) + |> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at], + empty_values: [nil] + ) |> validate_required([:id, :title, :created_at, :updated_at]) end end diff --git a/lib/bds/ai/chat_message.ex b/lib/bds/ai/chat_message.ex index b472368..1f9a3a0 100644 --- a/lib/bds/ai/chat_message.ex +++ b/lib/bds/ai/chat_message.ex @@ -23,18 +23,20 @@ defmodule BDS.AI.ChatMessage do def changeset(message, attrs) do message - |> cast(attrs, [ - :conversation_id, - :role, - :content, - :tool_call_id, - :tool_calls, - :token_usage_input, - :token_usage_output, - :cache_read_tokens, - :cache_write_tokens, - :created_at - ], empty_values: [nil]) + |> cast( + attrs, + [ + :conversation_id, + :role, + :content, + :tool_call_id, + :tool_calls, + :token_usage_input, + :token_usage_output, + :cache_read_tokens, + :cache_write_tokens, + :created_at + ], empty_values: [nil]) |> validate_required([:conversation_id, :role, :created_at]) |> assoc_constraint(:conversation) end diff --git a/lib/bds/ai/chat_tools.ex b/lib/bds/ai/chat_tools.ex index 7196111..7a97bc3 100644 --- a/lib/bds/ai/chat_tools.ex +++ b/lib/bds/ai/chat_tools.ex @@ -14,8 +14,10 @@ defmodule BDS.AI.ChatTools do project_id = project_id || active_project_id() %{ - post_count: Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id), - media_count: Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id), + post_count: + Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id), + media_count: + Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id), tag_count: Chat.count_distinct_string_list(Post, :tags, project_id), category_count: Chat.count_distinct_string_list(Post, :categories, project_id) } @@ -132,9 +134,28 @@ defmodule BDS.AI.ChatTools do project_tools = if is_binary(project_id) do [ - %{name: "blog_stats", spec: tool_spec("blog_stats", "Return aggregate blog statistics", %{"type" => "object", "properties" => %{}})}, - %{name: "list_posts", spec: tool_spec("list_posts", "List recent posts in the active project", limit_schema())}, - %{name: "list_media", spec: tool_spec("list_media", "List recent media items in the active project", limit_schema())} + %{ + name: "blog_stats", + spec: + tool_spec("blog_stats", "Return aggregate blog statistics", %{ + "type" => "object", + "properties" => %{} + }) + }, + %{ + name: "list_posts", + spec: + tool_spec("list_posts", "List recent posts in the active project", limit_schema()) + }, + %{ + name: "list_media", + spec: + tool_spec( + "list_media", + "List recent media items in the active project", + limit_schema() + ) + } ] else [] @@ -142,14 +163,62 @@ defmodule BDS.AI.ChatTools do project_tools ++ [ - %{name: "render_card", spec: tool_spec("render_card", "Return a structured card payload", render_card_schema())}, - %{name: "render_table", spec: tool_spec("render_table", "Return a structured table payload", render_table_schema())}, - %{name: "render_chart", spec: tool_spec("render_chart", "Return a structured chart payload", render_chart_schema())}, - %{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())}, - %{name: "render_metric", spec: tool_spec("render_metric", "Return a structured metric payload", render_metric_schema())}, - %{name: "render_list", spec: tool_spec("render_list", "Return a structured list payload", render_list_schema())}, - %{name: "render_tabs", spec: tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())}, - %{name: "render_mindmap", spec: tool_spec("render_mindmap", "Return a structured mindmap payload", render_mindmap_schema())} + %{ + name: "render_card", + spec: + tool_spec("render_card", "Return a structured card payload", render_card_schema()) + }, + %{ + name: "render_table", + spec: + tool_spec( + "render_table", + "Return a structured table payload", + render_table_schema() + ) + }, + %{ + name: "render_chart", + spec: + tool_spec( + "render_chart", + "Return a structured chart payload", + render_chart_schema() + ) + }, + %{ + name: "render_form", + spec: + tool_spec("render_form", "Return a structured form payload", render_form_schema()) + }, + %{ + name: "render_metric", + spec: + tool_spec( + "render_metric", + "Return a structured metric payload", + render_metric_schema() + ) + }, + %{ + name: "render_list", + spec: + tool_spec("render_list", "Return a structured list payload", render_list_schema()) + }, + %{ + name: "render_tabs", + spec: + tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema()) + }, + %{ + name: "render_mindmap", + spec: + tool_spec( + "render_mindmap", + "Return a structured mindmap payload", + render_mindmap_schema() + ) + } ] else [] diff --git a/lib/bds/ai/http_client.ex b/lib/bds/ai/http_client.ex index 1e73f4d..67ca673 100644 --- a/lib/bds/ai/http_client.ex +++ b/lib/bds/ai/http_client.ex @@ -2,7 +2,11 @@ defmodule BDS.AI.HttpClient do @moduledoc false def get(url, headers) when is_binary(url) and is_map(headers) do - request = {String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end)} + request = + {String.to_charlist(url), + Enum.map(headers, fn {key, value} -> + {String.to_charlist(key), String.to_charlist(value)} + end)} :inets.start() :ssl.start() @@ -24,7 +28,10 @@ defmodule BDS.AI.HttpClient do def post(url, headers, body) when is_binary(url) and is_map(headers) and is_binary(body) do request = - {String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end), ~c"application/json", body} + {String.to_charlist(url), + Enum.map(headers, fn {key, value} -> + {String.to_charlist(key), String.to_charlist(value)} + end), ~c"application/json", body} :inets.start() :ssl.start() diff --git a/lib/bds/ai/model.ex b/lib/bds/ai/model.ex index f2132cd..dad8e02 100644 --- a/lib/bds/ai/model.ex +++ b/lib/bds/ai/model.ex @@ -34,31 +34,41 @@ defmodule BDS.AI.Model do def changeset(model, attrs) do model - |> cast(attrs, [ + |> cast( + attrs, + [ + :provider, + :model_id, + :name, + :family, + :supports_attachment, + :supports_reasoning, + :supports_tool_calls, + :supports_structured_output, + :supports_temperature, + :knowledge, + :release_date, + :last_updated_date, + :open_weights, + :input_price, + :output_price, + :cache_read_price, + :cache_write_price, + :context_window, + :max_input_tokens, + :max_output_tokens, + :interleaved, + :status, + :updated_at + ], empty_values: [nil]) + |> validate_required([ :provider, :model_id, :name, - :family, - :supports_attachment, - :supports_reasoning, - :supports_tool_calls, - :supports_structured_output, - :supports_temperature, - :knowledge, - :release_date, - :last_updated_date, - :open_weights, - :input_price, - :output_price, - :cache_read_price, - :cache_write_price, :context_window, :max_input_tokens, :max_output_tokens, - :interleaved, - :status, :updated_at - ], empty_values: [nil]) - |> validate_required([:provider, :model_id, :name, :context_window, :max_input_tokens, :max_output_tokens, :updated_at]) + ]) end end diff --git a/lib/bds/ai/openai_compatible_runtime.ex b/lib/bds/ai/openai_compatible_runtime.ex index 7014d3f..2d77b96 100644 --- a/lib/bds/ai/openai_compatible_runtime.ex +++ b/lib/bds/ai/openai_compatible_runtime.ex @@ -30,12 +30,13 @@ defmodule BDS.AI.OpenAICompatibleRuntime do } |> maybe_put_auth(endpoint.api_key) - payload = %{ - "model" => request.model, - "messages" => request.messages, - "max_tokens" => request.max_output_tokens - } - |> maybe_put_tools(request.tools) + payload = + %{ + "model" => request.model, + "messages" => request.messages, + "max_tokens" => request.max_output_tokens + } + |> maybe_put_tools(request.tools) with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)), 200 <- response.status do @@ -55,7 +56,9 @@ defmodule BDS.AI.OpenAICompatibleRuntime do json = case content do - nil -> nil + nil -> + nil + value when is_binary(value) -> case Jason.decode(value) do {:ok, decoded} when is_map(decoded) -> decoded @@ -77,10 +80,17 @@ defmodule BDS.AI.OpenAICompatibleRuntime do defp models_url(url) do cond do - String.ends_with?(url, "/chat/completions") -> String.replace_suffix(url, "/chat/completions", "/models") - String.ends_with?(url, "/models") -> url - String.ends_with?(url, "/") -> url <> "models" - true -> url <> "/models" + String.ends_with?(url, "/chat/completions") -> + String.replace_suffix(url, "/chat/completions", "/models") + + String.ends_with?(url, "/models") -> + url + + String.ends_with?(url, "/") -> + url <> "models" + + true -> + url <> "/models" end end @@ -114,7 +124,9 @@ defmodule BDS.AI.OpenAICompatibleRuntime do defp maybe_put_auth(headers, nil), do: headers defp maybe_put_auth(headers, ""), do: headers - defp maybe_put_auth(headers, api_key), do: Map.put(headers, "authorization", "Bearer #{api_key}") + + defp maybe_put_auth(headers, api_key), + do: Map.put(headers, "authorization", "Bearer #{api_key}") defp maybe_put_tools(payload, []), do: payload defp maybe_put_tools(payload, nil), do: payload diff --git a/lib/bds/ai/runtime.ex b/lib/bds/ai/runtime.ex index 6f942eb..d3ed798 100644 --- a/lib/bds/ai/runtime.ex +++ b/lib/bds/ai/runtime.ex @@ -65,7 +65,9 @@ defmodule BDS.AI.Runtime do end defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do - {:ok, Keyword.get(extra, :model) || model_preference_value(:airplane_image_analysis) || endpoint.model} + {:ok, + Keyword.get(extra, :model) || model_preference_value(:airplane_image_analysis) || + endpoint.model} end defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do @@ -83,7 +85,8 @@ defmodule BDS.AI.Runtime do defp fetch_endpoint_for_mode(mode, secret_backend) do with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) do case endpoint do - %{url: url, model: model} = loaded when is_binary(url) and url != "" and is_binary(model) and model != "" -> + %{url: url, model: model} = loaded + when is_binary(url) and url != "" and is_binary(model) and model != "" -> if mode == :online and blank?(loaded.api_key) do {:error, %{kind: :endpoint_not_configured, endpoint: mode}} else diff --git a/lib/bds/ai/secret_backend.ex b/lib/bds/ai/secret_backend.ex index 9e92f32..4885f53 100644 --- a/lib/bds/ai/secret_backend.ex +++ b/lib/bds/ai/secret_backend.ex @@ -17,7 +17,15 @@ defmodule BDS.AI.SecretBackend do with {:ok, binary} <- Base.decode64(encoded), <> <- binary, plaintext when is_binary(plaintext) <- - :crypto.crypto_one_time_aead(:aes_256_gcm, secret_key(), iv, ciphertext, @aad, tag, false) do + :crypto.crypto_one_time_aead( + :aes_256_gcm, + secret_key(), + iv, + ciphertext, + @aad, + tag, + false + ) do {:ok, plaintext} else _other -> {:error, :invalid_ciphertext} diff --git a/lib/bds/ai/settings_store.ex b/lib/bds/ai/settings_store.ex index 64ff349..0e86196 100644 --- a/lib/bds/ai/settings_store.ex +++ b/lib/bds/ai/settings_store.ex @@ -21,8 +21,8 @@ defmodule BDS.AI.SettingsStore do def put_setting(key, value) when is_binary(key) and is_binary(value) do now = Persistence.now_ms() - (%Setting{} - |> Setting.changeset(%{key: key, value: value, updated_at: now})) + %Setting{} + |> Setting.changeset(%{key: key, value: value, updated_at: now}) |> Repo.insert( on_conflict: [set: [value: value, updated_at: now]], conflict_target: [:key] diff --git a/lib/bds/cli_sync.ex b/lib/bds/cli_sync.ex index 24c07db..de18f24 100644 --- a/lib/bds/cli_sync.ex +++ b/lib/bds/cli_sync.ex @@ -39,12 +39,18 @@ defmodule BDS.CliSync do ids = Enum.map(notifications, & &1.id) if ids != [] do - Repo.update_all(from(notification in Notification, where: notification.id in ^ids), set: [seen_at: now]) + Repo.update_all(from(notification in Notification, where: notification.id in ^ids), + set: [seen_at: now] + ) end {:ok, Enum.map(notifications, fn notification -> - %{entity_type: notification.entity_type, entity_id: notification.entity_id, action: notification.action} + %{ + entity_type: notification.entity_type, + entity_id: notification.entity_id, + action: notification.action + } end)} end @@ -52,13 +58,17 @@ defmodule BDS.CliSync do {processed_count, _} = Repo.delete_all( from notification in Notification, - where: not is_nil(notification.seen_at) and notification.created_at <= ^(now - @processed_ttl_ms) + where: + not is_nil(notification.seen_at) and + notification.created_at <= ^(now - @processed_ttl_ms) ) {unprocessed_count, _} = Repo.delete_all( from notification in Notification, - where: is_nil(notification.seen_at) and notification.created_at <= ^(now - @unprocessed_ttl_ms) + where: + is_nil(notification.seen_at) and + notification.created_at <= ^(now - @unprocessed_ttl_ms) ) {:ok, %{processed: processed_count, unprocessed: unprocessed_count}} diff --git a/lib/bds/cli_sync/notification.ex b/lib/bds/cli_sync/notification.ex index bd37c9e..0913f2b 100644 --- a/lib/bds/cli_sync/notification.ex +++ b/lib/bds/cli_sync/notification.ex @@ -15,7 +15,9 @@ defmodule BDS.CliSync.Notification do def changeset(notification, attrs) do notification - |> cast(attrs, [:entity_type, :entity_id, :action, :from_cli, :seen_at, :created_at], empty_values: [nil]) + |> cast(attrs, [:entity_type, :entity_id, :action, :from_cli, :seen_at, :created_at], + empty_values: [nil] + ) |> validate_required([:entity_type, :entity_id, :action, :from_cli, :created_at]) end end diff --git a/lib/bds/cli_sync/watcher.ex b/lib/bds/cli_sync/watcher.ex index a30b600..be35fc5 100644 --- a/lib/bds/cli_sync/watcher.ex +++ b/lib/bds/cli_sync/watcher.ex @@ -24,7 +24,11 @@ defmodule BDS.CliSync.Watcher do @impl true def init(opts) do state = %{ - poll_interval_ms: normalize_positive_integer(Keyword.get(opts, :poll_interval_ms), @default_poll_interval_ms), + poll_interval_ms: + normalize_positive_integer( + Keyword.get(opts, :poll_interval_ms), + @default_poll_interval_ms + ), pubsub: Keyword.get(opts, :pubsub, BDS.PubSub) } @@ -49,7 +53,11 @@ defmodule BDS.CliSync.Watcher do {:ok, _pruned} = CliSync.prune_notifications() Enum.each(notifications, fn notification -> - Phoenix.PubSub.broadcast(state.pubsub, topic(), {:entity_changed, notification_payload(notification)}) + Phoenix.PubSub.broadcast( + state.pubsub, + topic(), + {:entity_changed, notification_payload(notification)} + ) end) state diff --git a/lib/bds/desktop/automation.ex b/lib/bds/desktop/automation.ex index 523978f..8e0031a 100644 --- a/lib/bds/desktop/automation.ex +++ b/lib/bds/desktop/automation.ex @@ -107,7 +107,9 @@ defmodule BDS.Desktop.Automation do end def handle_call({:native_menu_action, action}, _from, state) do - {reply, state} = driver_request(state, %{"command" => "native_menu_action", "action" => action}) + {reply, state} = + driver_request(state, %{"command" => "native_menu_action", "action" => action}) + {:reply, normalize_simple_reply(reply), state} end @@ -204,7 +206,9 @@ defmodule BDS.Desktop.Automation do receive_driver_message(state, @request_timeout, fn message -> case message do - %{"ref" => ^ref, "status" => "ok", "result" => result} -> {:ok, result} + %{"ref" => ^ref, "status" => "ok", "result" => result} -> + {:ok, result} + %{"ref" => ^ref, "status" => "error", "message" => reason} -> raise "desktop automation request failed: #{reason}" @@ -242,7 +246,8 @@ defmodule BDS.Desktop.Automation do defp process_driver_messages(state, deadline, matcher) do {messages, buffer} = split_driver_buffer(state.driver_buffer) - case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, {acc, _} -> + case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, + {acc, _} -> case decode_driver_message(message) do :skip -> {:cont, {acc, nil}} @@ -259,7 +264,11 @@ defmodule BDS.Desktop.Automation do receive do {port, {:data, data}} when port == state.driver_port -> - process_driver_messages(%{state | driver_buffer: state.driver_buffer <> data}, deadline, matcher) + process_driver_messages( + %{state | driver_buffer: state.driver_buffer <> data}, + deadline, + matcher + ) {port, {:exit_status, status}} when port == state.driver_port -> raise "desktop automation driver exited with status #{status}" @@ -311,7 +320,9 @@ defmodule BDS.Desktop.Automation do defp do_wait_for_server(base_url, deadline) do case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do - {:ok, {{_, 200, _}, _headers, _body}} -> :ok + {:ok, {{_, 200, _}, _headers, _body}} -> + :ok + _other -> if System.monotonic_time(:millisecond) >= deadline do raise "desktop app process did not become healthy in time" diff --git a/lib/bds/desktop/endpoint.ex b/lib/bds/desktop/endpoint.ex index 0aded4d..63e092f 100644 --- a/lib/bds/desktop/endpoint.ex +++ b/lib/bds/desktop/endpoint.ex @@ -9,28 +9,30 @@ defmodule BDS.Desktop.Endpoint do signing_salt: "desktop-shell" ] - socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: @session_options]] + socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]) - plug Plug.Session, @session_options - plug :maybe_require_desktop_auth + plug(Plug.Session, @session_options) + plug(:maybe_require_desktop_auth) - plug Plug.Static, + plug(Plug.Static, at: "/assets", from: {:bds, "priv/ui"}, only: ["app.css", "live.js", "monaco"] + ) - plug Plug.Static, + plug(Plug.Static, at: "/vendor/phoenix", from: {:phoenix, "priv/static"}, only: ["phoenix.min.js"] + ) - plug Plug.Static, + plug(Plug.Static, at: "/vendor/live_view", from: {:phoenix_live_view, "priv/static"}, only: ["phoenix_live_view.min.js"] + ) - plug BDS.Desktop.Router + plug(BDS.Desktop.Router) defp maybe_require_desktop_auth(conn, _opts) do if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do diff --git a/lib/bds/desktop/main_window.ex b/lib/bds/desktop/main_window.ex index 7a4605a..94b59db 100644 --- a/lib/bds/desktop/main_window.ex +++ b/lib/bds/desktop/main_window.ex @@ -22,7 +22,9 @@ defmodule BDS.Desktop.MainWindow do restored = restore_bounds() {default_width, default_height} = Keyword.get(desktop_config, :window_size, @default_size) {min_width, min_height} = Keyword.get(desktop_config, :window_min_size, @default_min_size) - startup_bounds = clamp_startup_bounds(restored || %{width: default_width, height: default_height}) + + startup_bounds = + clamp_startup_bounds(restored || %{width: default_width, height: default_height}) base_opts = [ app: :bds, @@ -70,7 +72,9 @@ defmodule BDS.Desktop.MainWindow do frame -> apply_restored_bounds(frame) schedule_persist() - {:noreply, %{state | frame: frame, last_bounds: current_bounds(frame) || state.last_bounds}} + + {:noreply, + %{state | frame: frame, last_bounds: current_bounds(frame) || state.last_bounds}} end end @@ -124,9 +128,15 @@ defmodule BDS.Desktop.MainWindow do defp current_bounds(frame) do with_wx_env(fn -> cond do - not :wxWindow.isShown(frame) -> nil - :wxTopLevelWindow.isFullScreen(frame) -> nil - :wxTopLevelWindow.isMaximized(frame) -> nil + not :wxWindow.isShown(frame) -> + nil + + :wxTopLevelWindow.isFullScreen(frame) -> + nil + + :wxTopLevelWindow.isMaximized(frame) -> + nil + true -> {x, y} = :wxWindow.getPosition(frame) {width, height} = :wxWindow.getSize(frame) @@ -160,7 +170,8 @@ defmodule BDS.Desktop.MainWindow do end defp normalize_bounds(%{x: x, y: y, width: width, height: height}) - when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) and width > 0 and height > 0 do + when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) and + width > 0 and height > 0 do {:ok, %{x: x, y: y, width: width, height: height}} end @@ -180,7 +191,8 @@ defmodule BDS.Desktop.MainWindow do desktop_config = Application.get_env(:bds, :desktop, []) case Keyword.get(desktop_config, :window_client_area_override) do - {x, y, width, height} when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) -> + {x, y, width, height} + when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) -> %{x: x, y: y, width: width, height: height} _ -> diff --git a/lib/bds/desktop/media_controller.ex b/lib/bds/desktop/media_controller.ex index c48536e..c68635d 100644 --- a/lib/bds/desktop/media_controller.ex +++ b/lib/bds/desktop/media_controller.ex @@ -24,7 +24,8 @@ defmodule BDS.Desktop.MediaController do with %{} = project <- Projects.get_active_project(), %MediaRecord{} = media <- Repo.get(MediaRecord, media_id), true <- media.project_id == project.id, - relative_path when is_binary(relative_path) <- Media.thumbnail_paths(media)[thumbnail_size(size)], + relative_path when is_binary(relative_path) <- + Media.thumbnail_paths(media)[thumbnail_size(size)], absolute_path = Path.join(Projects.project_data_dir(project), relative_path), true <- File.exists?(absolute_path) do {:ok, thumbnail_content_type(relative_path), absolute_path} @@ -33,7 +34,8 @@ defmodule BDS.Desktop.MediaController do end rescue error in [Exqlite.Error, DBConnection.OwnershipError] -> - if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do + if match?(%Exqlite.Error{}, error) and + not String.contains?(Exception.message(error), "no such table") do reraise error, __STACKTRACE__ end diff --git a/lib/bds/desktop/overlay.ex b/lib/bds/desktop/overlay.ex index a9a0591..7632bdd 100644 --- a/lib/bds/desktop/overlay.ex +++ b/lib/bds/desktop/overlay.ex @@ -168,14 +168,16 @@ defmodule BDS.Desktop.Overlay do def close_lightbox(%{kind: :gallery} = overlay), do: %{overlay | lightbox: nil} def close_lightbox(overlay), do: overlay - def lightbox_next(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) when is_map(lightbox) and images != [] do + def lightbox_next(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) + when is_map(lightbox) and images != [] do next_index = rem(lightbox.current_index + 1, length(images)) %{overlay | lightbox: lightbox_from_index(images, next_index)} end def lightbox_next(overlay), do: overlay - def lightbox_previous(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) when is_map(lightbox) and images != [] do + def lightbox_previous(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) + when is_map(lightbox) and images != [] do next_index = rem(lightbox.current_index - 1 + length(images), length(images)) %{overlay | lightbox: lightbox_from_index(images, next_index)} end diff --git a/lib/bds/desktop/router.ex b/lib/bds/desktop/router.ex index b5d1590..7aceba3 100644 --- a/lib/bds/desktop/router.ex +++ b/lib/bds/desktop/router.ex @@ -6,23 +6,23 @@ defmodule BDS.Desktop.Router do import Phoenix.LiveView.Router pipeline :browser do - plug :accepts, ["html"] - plug :fetch_session - plug :fetch_live_flash - plug :put_root_layout, html: {BDS.Desktop.Layouts, :root} - plug :protect_from_forgery - plug :put_secure_browser_headers + plug(:accepts, ["html"]) + plug(:fetch_session) + plug(:fetch_live_flash) + plug(:put_root_layout, html: {BDS.Desktop.Layouts, :root}) + plug(:protect_from_forgery) + plug(:put_secure_browser_headers) end scope "/", BDS.Desktop do - pipe_through :browser + pipe_through(:browser) - get "/health", HealthController, :show - get "/media-thumbnail/:media_id", MediaController, :thumbnail + get("/health", HealthController, :show) + get("/media-thumbnail/:media_id", MediaController, :thumbnail) live_session :desktop_shell, root_layout: {BDS.Desktop.Layouts, :root} do - live "/", ShellLive, :index + live("/", ShellLive, :index) end end end diff --git a/lib/bds/desktop/shell_data.ex b/lib/bds/desktop/shell_data.ex index 93fdcdc..7353307 100644 --- a/lib/bds/desktop/shell_data.ex +++ b/lib/bds/desktop/shell_data.ex @@ -38,7 +38,8 @@ defmodule BDS.Desktop.ShellData do Projects.shell_snapshot() rescue error in [Exqlite.Error, DBConnection.OwnershipError] -> - if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table: projects") do + if match?(%Exqlite.Error{}, error) and + not String.contains?(Exception.message(error), "no such table: projects") do reraise error, __STACKTRACE__ end @@ -54,7 +55,8 @@ defmodule BDS.Desktop.ShellData do Dashboard.snapshot(project_id) rescue error in [Exqlite.Error, DBConnection.OwnershipError] -> - if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do + if match?(%Exqlite.Error{}, error) and + not String.contains?(Exception.message(error), "no such table") do reraise error, __STACKTRACE__ end @@ -65,7 +67,8 @@ defmodule BDS.Desktop.ShellData do Sidebar.view(project_id, view_id, params) rescue error in [Exqlite.Error, DBConnection.OwnershipError] -> - if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do + if match?(%Exqlite.Error{}, error) and + not String.contains?(Exception.message(error), "no such table") do reraise error, __STACKTRACE__ end @@ -75,7 +78,10 @@ defmodule BDS.Desktop.ShellData do def assistant_cards do [ %{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."}, - %{label: "Filesystem Sync", text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."}, + %{ + label: "Filesystem Sync", + text: "Metadata flush, diffing, and rebuild hooks still need editor wiring." + }, %{label: "Desktop Runtime", text: "The app window is now served from LiveView state."} ] end @@ -117,7 +123,8 @@ defmodule BDS.Desktop.ShellData do end rescue error in [DBConnection.OwnershipError, Exqlite.Error] -> - if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do + if match?(%Exqlite.Error{}, error) and + not String.contains?(Exception.message(error), "no such table") do reraise error, __STACKTRACE__ end @@ -146,17 +153,38 @@ defmodule BDS.Desktop.ShellData do def activity_icon(id) do case to_string(id) do - "posts" -> ~s() - "pages" -> ~s() - "media" -> ~s() - "scripts" -> ~s() - "templates" -> ~s() - "tags" -> ~s() - "chat" -> ~s() - "import" -> ~s() - "git" -> ~s() - "settings" -> ~s() - _other -> activity_icon("posts") + "posts" -> + ~s() + + "pages" -> + ~s() + + "media" -> + ~s() + + "scripts" -> + ~s() + + "templates" -> + ~s() + + "tags" -> + ~s() + + "chat" -> + ~s() + + "import" -> + ~s() + + "git" -> + ~s() + + "settings" -> + ~s() + + _other -> + activity_icon("posts") end end @@ -171,7 +199,10 @@ defmodule BDS.Desktop.ShellData do def dashboard_post_count_label(count) do normalized_count = count || 0 - key = if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other" + + key = + if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other" + translate(key, %{count: normalized_count}) end @@ -188,7 +219,7 @@ defmodule BDS.Desktop.ShellData do top_items |> Enum.map(fn item -> - font_size = 11 + (((item.count || 0) - min_count) / range) * 11 + font_size = 11 + ((item.count || 0) - min_count) / range * 11 Map.merge(item, %{font_size: font_size, color: normalize_dashboard_tag_color(item.color)}) end) |> Enum.sort_by(&String.downcase(to_string(&1.tag || ""))) @@ -199,10 +230,11 @@ defmodule BDS.Desktop.ShellData do declarations = if item.color do - declarations ++ [ - "background-color: #{item.color};", - "color: #{dashboard_contrast_color(item.color)};" - ] + declarations ++ + [ + "background-color: #{item.color};", + "color: #{dashboard_contrast_color(item.color)};" + ] else declarations end @@ -225,9 +257,17 @@ defmodule BDS.Desktop.ShellData do def route_label(route) do case to_string(route) do - "git_log" -> "Git Log" - "post_links" -> "Post Links" - other -> other |> String.replace("_", " ") |> String.split() |> Enum.map_join(" ", &String.capitalize/1) + "git_log" -> + "Git Log" + + "post_links" -> + "Post Links" + + other -> + other + |> String.replace("_", " ") + |> String.split() + |> Enum.map_join(" ", &String.capitalize/1) end end @@ -255,7 +295,10 @@ defmodule BDS.Desktop.ShellData do defp effective_ui_language(locale), do: locale defp maybe_add_panel_tab(tabs, :post, :post_links), do: tabs ++ [:post_links] - defp maybe_add_panel_tab(tabs, route, :git_log) when route in [:post, :media], do: tabs ++ [:git_log] + + defp maybe_add_panel_tab(tabs, route, :git_log) when route in [:post, :media], + do: tabs ++ [:git_log] + defp maybe_add_panel_tab(tabs, _route, _tab), do: tabs defp default_project_snapshot do diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index 93cf4fe..5273a85 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -8,10 +8,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do alias BDS.Desktop.ShellData alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking} - embed_templates "chat_editor_html/*" + embed_templates("chat_editor_html/*") # ── Public API: state assignment ─────────────────────────────────────────── + @spec assign_socket(term()) :: term() def assign_socket(socket) do assign(socket, :chat_editor, MessageBuild.build(socket.assigns)) end @@ -25,6 +26,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do # ── Public API: input + surface state ────────────────────────────────────── + @spec update_input(term(), term(), term()) :: term() def update_input(socket, value, reload) do %{id: conversation_id} = socket.assigns.current_tab @@ -36,6 +38,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do |> reload.(socket.assigns.workbench) end + @spec update_surface_form(term(), term(), term(), term()) :: term() def update_surface_form(socket, surface_id, fields, reload) when is_binary(surface_id) and is_map(fields) do next_data = Map.put(socket.assigns.chat_editor_surface_data, surface_id, fields) @@ -45,6 +48,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do |> reload.(socket.assigns.workbench) end + @spec select_surface_tab(term(), term(), term(), term()) :: term() def select_surface_tab(socket, surface_id, index, reload) when is_binary(surface_id) and is_integer(index) and index >= 0 do socket @@ -55,10 +59,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do |> reload.(socket.assigns.workbench) end + @spec current_surface_data(term(), term()) :: term() def current_surface_data(socket, surface_id) when is_binary(surface_id) do Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{}) end + @spec set_action_error(term(), term(), term(), term()) :: term() def set_action_error(socket, conversation_id, message, reload) when is_binary(conversation_id) and is_binary(message) do socket @@ -69,6 +75,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do |> reload.(socket.assigns.workbench) end + @spec clear_action_error(term(), term(), term()) :: term() def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do socket |> assign( @@ -80,6 +87,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do # ── Public API: messaging ────────────────────────────────────────────────── + @spec send_message(term(), term(), term()) :: term() def send_message(socket, reload, append_output) do %{id: conversation_id} = socket.assigns.current_tab message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim() @@ -144,6 +152,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do end end + @spec abort_message(term(), term()) :: term() def abort_message(socket, reload) do %{id: conversation_id} = socket.assigns.current_tab @@ -167,6 +176,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do end end + @spec note_tool_call(term(), term(), term(), term()) :: term() def note_tool_call(socket, conversation_id, tool_call, reload) when is_binary(conversation_id) and is_map(tool_call) do update_request( @@ -189,6 +199,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do ) end + @spec note_tool_result(term(), term(), term(), term()) :: term() def note_tool_result(socket, conversation_id, name, reload) when is_binary(conversation_id) and is_binary(name) do update_request( @@ -201,6 +212,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do ) end + @spec note_streaming_content(term(), term(), term(), term()) :: term() def note_streaming_content(socket, conversation_id, content, reload) when is_binary(conversation_id) and is_binary(content) do update_request( @@ -211,6 +223,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do ) end + @spec finish_request(term(), term(), term(), term(), term()) :: term() def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do case Map.pop(socket.assigns.chat_editor_request_refs, ref) do {nil, _remaining_refs} -> @@ -245,12 +258,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do # ── HEEx-callable helpers ───────────────────────────────────────────────── + @spec message_role_label(term()) :: term() def message_role_label(:user), do: translated("chat.role.you") def message_role_label(_role), do: translated("chat.role.assistant") defdelegate tool_call_name(tool_call), to: ToolTracking defdelegate tool_call_arguments(tool_call), to: ToolTracking + @spec tool_surface_type(term()) :: term() def tool_surface_type(surface), do: Map.get(surface, :type, "json") def markdown_html(content) when is_binary(content) do @@ -264,8 +279,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do raw(html) end + @spec markdown_html(term()) :: term() def markdown_html(_content), do: "" + @spec payload_json(term()) :: term() def payload_json(nil), do: "{}" def payload_json(payload) when is_map(payload), do: Jason.encode!(payload) @@ -280,15 +297,18 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do |> Float.round(2) end + @spec chart_width(term(), term()) :: term() def chart_width(_max_value, _value), do: 0 def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true + @spec truthy?(term()) :: term() def truthy?(_value), do: false # ── HEEx components ─────────────────────────────────────────────────────── - attr :markers, :list, required: true + attr(:markers, :list, required: true) + @spec chat_tool_markers(term()) :: term() def chat_tool_markers(assigns) do ~H""" <%= if @markers != [] do %> @@ -307,8 +327,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do """ end - attr :surface, :map, required: true + attr(:surface, :map, required: true) + @spec chat_surface(term()) :: term() def chat_surface(assigns) do ~H"""
@@ -548,7 +569,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do fn _match, src, alt -> external_image_link(src, alt) end ) - Regex.replace(~r/]*\bsrc="(https?:\/\/[^\"]+)")[^>]*\/?>/i, html, fn _match, src -> + Regex.replace(~r/]*\bsrc="(https?:\/\/[^\"]+)")[^>]*\/?>/i, html, fn _match, + src -> external_image_link(src, src) end) end @@ -571,6 +593,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do defp format_error(reason), do: inspect(reason) + @spec translated(term(), term()) :: term() def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) end diff --git a/lib/bds/desktop/shell_live/chat_editor/message_build.ex b/lib/bds/desktop/shell_live/chat_editor/message_build.ex index 45e4143..ea1d893 100644 --- a/lib/bds/desktop/shell_live/chat_editor/message_build.ex +++ b/lib/bds/desktop/shell_live/chat_editor/message_build.ex @@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do alias BDS.Desktop.ShellData alias BDS.Desktop.ShellLive.ChatEditor.{ModelSelection, ToolSurfaces, ToolTracking} + @spec build(term()) :: term() def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do case AI.get_chat_conversation(conversation_id) do nil -> diff --git a/lib/bds/desktop/shell_live/chat_editor/model_selection.ex b/lib/bds/desktop/shell_live/chat_editor/model_selection.ex index 888c8d2..ebfca46 100644 --- a/lib/bds/desktop/shell_live/chat_editor/model_selection.ex +++ b/lib/bds/desktop/shell_live/chat_editor/model_selection.ex @@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do import Phoenix.Component, only: [assign: 3] + @spec toggle_model_selector(term(), term()) :: term() def toggle_model_selector(socket, reload) do %{id: conversation_id} = socket.assigns.current_tab current = Map.get(socket.assigns.chat_model_selectors_open, conversation_id, false) @@ -18,6 +19,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do |> reload.(socket.assigns.workbench) end + @spec set_model(term(), term(), term(), term()) :: term() def set_model(socket, model_id, reload, append_output) do %{id: conversation_id} = socket.assigns.current_tab @@ -37,6 +39,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do end end + @spec group_available_models(term()) :: term() def group_available_models(models) when is_list(models) do models |> Enum.group_by(&Map.get(&1, :provider, "other")) @@ -54,6 +57,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do |> Enum.sort_by(&String.downcase(to_string(&1.label))) end + @spec needs_api_key?(term()) :: term() def needs_api_key?(true), do: false def needs_api_key?(false) do diff --git a/lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex b/lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex index a58c584..7965199 100644 --- a/lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex +++ b/lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex @@ -14,9 +14,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do "render_tabs" ]) + @spec render_tool?(term()) :: term() def render_tool?(name) when is_binary(name), do: MapSet.member?(@render_tool_names, name) + @spec render_tool?(term()) :: term() def render_tool?(_name), do: false + @spec build_render_surfaces(term(), term(), term()) :: term() def build_render_surfaces(tool_calls, message_id, assigns) do tool_calls |> Enum.with_index() @@ -28,6 +31,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do end) end + @spec build_render_surface(term(), term(), term()) :: term() def build_render_surface(%{name: name, arguments: arguments}, surface_id, assigns) do if MapSet.member?(@render_tool_names, name) do do_build_render_surface(name, arguments || %{}, surface_id, assigns) @@ -51,6 +55,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do end end + @spec normalize_tool_surface(term()) :: term() def normalize_tool_surface(_content), do: nil defp do_build_render_surface("render_card", arguments, surface_id, _assigns) do @@ -150,7 +155,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do label: map_value(field, "label", key), input_type: map_value(field, "inputType") || map_value(field, "input_type", "text"), placeholder: map_value(field, "placeholder"), - value: Map.get(stored_fields, key, map_value(field, "defaultValue") || map_value(field, "default_value")), + value: + Map.get( + stored_fields, + key, + map_value(field, "defaultValue") || map_value(field, "default_value") + ), options: decode_surface_options(map_value(field, "options", [])), required?: truthy?(map_value(field, "required", false)) } @@ -161,8 +171,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do type: "form", title: map_value(arguments, "title"), fields: fields, - submit_label: map_value(arguments, "submitLabel") || map_value(arguments, "submit_label", translated("chat.stop")), - submit_action: map_value(arguments, "submitAction") || map_value(arguments, "submit_action", "submitForm") + submit_label: + map_value(arguments, "submitLabel") || + map_value(arguments, "submit_label", translated("chat.stop")), + submit_action: + map_value(arguments, "submitAction") || + map_value(arguments, "submit_action", "submitForm") } end @@ -181,7 +195,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do |> List.wrap() |> Enum.with_index() |> Enum.map(fn {content, content_index} -> - build_tab_surface(content, "#{surface_id}-tab-#{tab_index}-#{content_index}", assigns) + build_tab_surface( + content, + "#{surface_id}-tab-#{tab_index}-#{content_index}", + assigns + ) end) } end) @@ -203,11 +221,21 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do type = map_value(content, "type", "text") case type do - render_type when render_type in ["card", "chart", "form", "list", "metric", "mindmap", "table", "tabs"] -> - do_build_render_surface("render_#{render_type}", Map.delete(content, "type"), surface_id, assigns) + render_type + when render_type in ["card", "chart", "form", "list", "metric", "mindmap", "table", "tabs"] -> + do_build_render_surface( + "render_#{render_type}", + Map.delete(content, "type"), + surface_id, + assigns + ) "text" -> - %{id: surface_id, type: "text", body: map_value(content, "body") || map_value(content, "text", "")} + %{ + id: surface_id, + type: "text", + body: map_value(content, "body") || map_value(content, "text", "") + } _other -> %{id: surface_id, type: "json", raw: content} diff --git a/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex b/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex index d4a6004..f581955 100644 --- a/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex +++ b/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex @@ -3,10 +3,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do @tool_args_max_length 30 + @spec tool_call_name(term()) :: term() def tool_call_name(tool_call) when is_map(tool_call) do BDS.MapUtils.attr(tool_call, :name) || "tool" end + @spec tool_call_arguments(term()) :: term() def tool_call_arguments(tool_call) when is_map(tool_call) do BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{} end @@ -25,6 +27,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do end) end + @spec normalize_tool_calls(term()) :: term() def normalize_tool_calls(_tool_calls), do: [] def tool_arguments_preview(arguments) when is_map(arguments) do @@ -33,6 +36,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do |> Enum.join(", ") end + @spec tool_arguments_preview(term()) :: term() def tool_arguments_preview(_arguments), do: "" def mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do @@ -47,8 +51,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do end) end + @spec mark_tool_call_completed(term(), term()) :: term() def mark_tool_call_completed(entry, _tool_call_id), do: entry + @spec tool_markers_from_events(term()) :: term() def tool_markers_from_events(nil), do: [] def tool_markers_from_events(%{tool_events: tool_events}) do diff --git a/lib/bds/desktop/shell_live/code_entity_editor.ex b/lib/bds/desktop/shell_live/code_entity_editor.ex index dc93d83..a1e0718 100644 --- a/lib/bds/desktop/shell_live/code_entity_editor.ex +++ b/lib/bds/desktop/shell_live/code_entity_editor.ex @@ -10,12 +10,14 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do embed_templates("code_entity_editor_html/*") + @spec assign_socket(term()) :: term() def assign_socket(socket) do socket |> assign(:script_editor, build_script(socket.assigns)) |> assign(:template_editor, build_template(socket.assigns)) end + @spec update_script(term(), term(), term()) :: term() def update_script(socket, params, reload) do %{id: script_id} = socket.assigns.current_tab @@ -27,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do |> reload.(socket.assigns.workbench) end + @spec save_script(term(), term(), term()) :: term() def save_script(socket, reload, append_output) do %{id: script_id} = socket.assigns.current_tab @@ -62,6 +65,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do end end + @spec check_script(term(), term(), term()) :: term() def check_script(socket, reload, append_output) do %{id: script_id} = socket.assigns.current_tab @@ -82,6 +86,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do end end + @spec run_script(term(), term(), term()) :: term() def run_script(socket, reload, append_output) do %{id: script_id} = socket.assigns.current_tab @@ -111,6 +116,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do end end + @spec delete_script(term(), term(), term()) :: term() def delete_script(socket, reload, append_output) do %{id: script_id} = socket.assigns.current_tab @@ -124,6 +130,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do end end + @spec update_template(term(), term(), term()) :: term() def update_template(socket, params, reload) do %{id: template_id} = socket.assigns.current_tab @@ -139,6 +146,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do |> reload.(socket.assigns.workbench) end + @spec save_template(term(), term(), term()) :: term() def save_template(socket, reload, append_output) do %{id: template_id} = socket.assigns.current_tab @@ -169,6 +177,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do end end + @spec validate_template(term(), term(), term()) :: term() def validate_template(socket, reload, append_output) do %{id: template_id} = socket.assigns.current_tab @@ -195,6 +204,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do end end + @spec delete_template(term(), term(), term()) :: term() def delete_template(socket, reload, append_output) do %{id: template_id} = socket.assigns.current_tab @@ -211,6 +221,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do end end + @spec build_script(term()) :: term() def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do case Scripts.get_script(script_id) do nil -> @@ -236,6 +247,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do def build_script(_assigns), do: nil + @spec build_template(term()) :: term() def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do case Templates.get_template(template_id) do nil -> @@ -259,9 +271,11 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do def build_template(_assigns), do: nil + @spec translated(term(), term()) :: term() def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + @spec format_timestamp(term()) :: term() def format_timestamp(nil), do: "" def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp) diff --git a/lib/bds/desktop/shell_live/import_editor.ex b/lib/bds/desktop/shell_live/import_editor.ex index 097c040..e1d4caf 100644 --- a/lib/bds/desktop/shell_live/import_editor.ex +++ b/lib/bds/desktop/shell_live/import_editor.ex @@ -57,7 +57,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do total, detail, reload - ), to: ProgressTracking + ), + to: ProgressTracking defdelegate finish_execution(socket, ref, result, reload, append_output), to: ProgressTracking @@ -72,6 +73,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do defdelegate clear_taxonomy_mapping(socket, params, reload), to: TaxonomyEditing defdelegate analyze_taxonomy_ai(socket, reload, append_output), to: TaxonomyEditing + @spec assign_socket(term()) :: term() def assign_socket(socket) do case socket.assigns[:current_tab] do %{type: :import, id: definition_id} -> @@ -140,6 +142,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do end end + @spec toggle_section(term(), term(), term()) :: term() def toggle_section(socket, section, reload) do with %{id: definition_id} <- socket.assigns.current_tab, section_key @@ -171,6 +174,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do end end + @spec toggle_model_selector(term(), term()) :: term() def toggle_model_selector(socket, reload) do with %{id: definition_id} <- socket.assigns.current_tab do current = Map.get(socket.assigns.import_editor_model_selectors_open, definition_id, false) @@ -186,6 +190,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do end end + @spec select_ai_model(term(), term(), term()) :: term() def select_ai_model(socket, model_id, reload) do with %{id: definition_id} <- socket.assigns.current_tab do socket @@ -205,6 +210,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do attr(:import_editor, :map, required: true) + @spec import_editor(term()) :: term() def import_editor(assigns) do assigns = assigns @@ -547,6 +553,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do attr(:expanded, :boolean, required: true) attr(:section, :string, required: true) + @spec conflict_section(term()) :: term() def conflict_section(assigns) do ~H"""
@@ -597,6 +604,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do attr(:section, :string, required: true) attr(:show_type, :boolean, default: false) + @spec post_detail_section(term()) :: term() def post_detail_section(assigns) do ~H"""
@@ -646,6 +654,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do attr(:expanded, :boolean, required: true) attr(:section, :string, required: true) + @spec media_detail_section(term()) :: term() def media_detail_section(assigns) do ~H"""
@@ -685,6 +694,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do attr(:label, :string, required: true) attr(:stats, :map, required: true) + @spec stat_card(term()) :: term() def stat_card(assigns) do ~H"""
@@ -703,6 +713,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do attr(:label, :string, required: true) attr(:stats, :map, required: true) + @spec other_stat_card(term()) :: term() def other_stat_card(assigns) do ~H"""
@@ -720,6 +731,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do attr(:label, :string, required: true) attr(:stats, :map, required: true) + @spec media_stat_card(term()) :: term() def media_stat_card(assigns) do ~H"""
@@ -739,6 +751,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do attr(:label, :string, required: true) attr(:stats, :map, required: true) + @spec taxonomy_stat_card(term()) :: term() def taxonomy_stat_card(assigns) do ~H"""
@@ -759,6 +772,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do attr(:edit, :map, default: nil) attr(:type, :string, required: true) + @spec taxonomy_group(term()) :: term() def taxonomy_group(assigns) do ~H"""
diff --git a/lib/bds/desktop/shell_live/import_editor/analysis_state.ex b/lib/bds/desktop/shell_live/import_editor/analysis_state.ex index 9b37e46..de4cfee 100644 --- a/lib/bds/desktop/shell_live/import_editor/analysis_state.ex +++ b/lib/bds/desktop/shell_live/import_editor/analysis_state.ex @@ -4,20 +4,27 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do alias BDS.{ImportAnalysis, ImportDefinitions, Metadata} alias BDS.Desktop.{FilePicker, FolderPicker, ShellData} + @spec change_definition(term(), term(), term()) :: term() def change_definition(socket, params, reload) do with %{id: definition_id} <- socket.assigns.current_tab, - {:ok, _definition} <- ImportDefinitions.update_definition(definition_id, %{name: Map.get(params, "name", "")}) do + {:ok, _definition} <- + ImportDefinitions.update_definition(definition_id, %{name: Map.get(params, "name", "")}) do reload.(socket, socket.assigns.workbench) else _other -> reload.(socket, socket.assigns.workbench) end end + @spec select_uploads_folder(term(), term(), term()) :: term() def select_uploads_folder(socket, reload, append_output) do with %{id: definition_id} <- socket.assigns.current_tab do case FolderPicker.choose_directory(translated("importAnalysis.uploadsFolder")) do {:ok, uploads_folder_path} -> - {:ok, _definition} = ImportDefinitions.update_definition(definition_id, %{uploads_folder_path: uploads_folder_path}) + {:ok, _definition} = + ImportDefinitions.update_definition(definition_id, %{ + uploads_folder_path: uploads_folder_path + }) + reload.(socket, socket.assigns.workbench) :cancel -> @@ -33,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do end end + @spec select_and_analyze(term(), term(), term()) :: term() def select_and_analyze(socket, reload, append_output) do with %{id: definition_id} <- socket.assigns.current_tab, %{} = definition <- ImportDefinitions.get_definition(definition_id) do @@ -50,9 +58,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do task = Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> - ImportAnalysis.analyze_wxr(project_id, wxr_file_path, definition.uploads_folder_path, + ImportAnalysis.analyze_wxr( + project_id, + wxr_file_path, + definition.uploads_folder_path, on_progress: fn step, detail -> - send(live_view_pid, {:import_analysis_progress, definition_id, translate_phase(step), detail}) + send( + live_view_pid, + {:import_analysis_progress, definition_id, translate_phase(step), detail} + ) end ) end) @@ -70,8 +84,14 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do ref: task.ref }) ) - |> Phoenix.Component.assign(:import_editor_analysis_task_refs, Map.put(socket.assigns.import_editor_analysis_task_refs, task.ref, definition_id)) - |> Phoenix.Component.assign(:import_editor_execution_states, Map.delete(socket.assigns.import_editor_execution_states, definition_id)) + |> Phoenix.Component.assign( + :import_editor_analysis_task_refs, + Map.put(socket.assigns.import_editor_analysis_task_refs, task.ref, definition_id) + ) + |> Phoenix.Component.assign( + :import_editor_execution_states, + Map.delete(socket.assigns.import_editor_execution_states, definition_id) + ) |> reload.(socket.assigns.workbench) :cancel -> @@ -87,32 +107,50 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do end end + @spec note_analysis_progress(term(), term(), term(), term(), term()) :: term() def note_analysis_progress(socket, definition_id, step, detail, reload) do socket |> Phoenix.Component.assign( :import_editor_analysis_states, - Map.update(socket.assigns.import_editor_analysis_states, definition_id, default_analysis_state(), fn state -> - state - |> Map.put(:loading, true) - |> Map.put(:step, step) - |> Map.put(:detail, detail) - end) + Map.update( + socket.assigns.import_editor_analysis_states, + definition_id, + default_analysis_state(), + fn state -> + state + |> Map.put(:loading, true) + |> Map.put(:step, step) + |> Map.put(:detail, detail) + end + ) ) |> reload.(socket.assigns.workbench) end + @spec finish_analysis(term(), term(), term(), term(), term()) :: term() def finish_analysis(socket, ref, result, reload, append_output) do case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do nil -> socket definition_id -> - analysis_state = Map.get(socket.assigns.import_editor_analysis_states, definition_id, default_analysis_state()) + analysis_state = + Map.get( + socket.assigns.import_editor_analysis_states, + definition_id, + default_analysis_state() + ) socket = socket - |> Phoenix.Component.assign(:import_editor_analysis_task_refs, Map.delete(socket.assigns.import_editor_analysis_task_refs, ref)) - |> Phoenix.Component.assign(:import_editor_analysis_states, Map.delete(socket.assigns.import_editor_analysis_states, definition_id)) + |> Phoenix.Component.assign( + :import_editor_analysis_task_refs, + Map.delete(socket.assigns.import_editor_analysis_task_refs, ref) + ) + |> Phoenix.Component.assign( + :import_editor_analysis_states, + Map.delete(socket.assigns.import_editor_analysis_states, definition_id) + ) case result do {:ok, report} -> @@ -146,6 +184,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do end end + @spec handle_analysis_task_down(term(), term(), term(), term(), term()) :: term() def handle_analysis_task_down(socket, ref, message, reload, append_output) do case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do nil -> @@ -153,13 +192,20 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do definition_id -> socket - |> Phoenix.Component.assign(:import_editor_analysis_task_refs, Map.delete(socket.assigns.import_editor_analysis_task_refs, ref)) - |> Phoenix.Component.assign(:import_editor_analysis_states, Map.delete(socket.assigns.import_editor_analysis_states, definition_id)) + |> Phoenix.Component.assign( + :import_editor_analysis_task_refs, + Map.delete(socket.assigns.import_editor_analysis_task_refs, ref) + ) + |> Phoenix.Component.assign( + :import_editor_analysis_states, + Map.delete(socket.assigns.import_editor_analysis_states, definition_id) + ) |> append_output.(translated("activity.import"), message, nil, "error") |> reload.(socket.assigns.workbench) end end + @spec importable_counts(term()) :: term() def importable_counts(nil), do: %{total: 0, tags: 0, posts: 0, media: 0, pages: 0} def importable_counts(report) do @@ -171,25 +217,37 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do pages = importable_entity_count(Map.get(report.items, :pages, [])) media = importable_entity_count(Map.get(report.items, :media, [])) - %{total: tag_count + posts + pages + media, tags: tag_count, posts: posts, media: media, pages: pages} + %{ + total: tag_count + posts + pages + media, + tags: tag_count, + posts: posts, + media: media, + pages: pages + } end + @spec importable_entity_count(term()) :: term() def importable_entity_count(items) do Enum.count(items || [], fn item -> - item.status == "new" or (item.status == "conflict" and Map.get(item, :resolution, "ignore") not in ["ignore", "skip"]) + item.status == "new" or + (item.status == "conflict" and + Map.get(item, :resolution, "ignore") not in ["ignore", "skip"]) end) end + @spec detail_items(term(), term()) :: term() def detail_items(nil, _bucket), do: [] def detail_items(report, bucket) do get_in(report, [:details, bucket]) || get_in(report, [:items, bucket]) || [] end + @spec default_analysis_state() :: term() def default_analysis_state do %{loading: false, step: nil, detail: nil, file_path: nil, ref: nil} end + @spec default_sections() :: term() def default_sections do %{ post_conflicts: true, @@ -203,18 +261,22 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do } end + @spec default_author(term()) :: term() def default_author(project_id) do {:ok, metadata} = Metadata.get_project_metadata(project_id) Map.get(metadata, :default_author) end + @spec suggested_definition_name(term()) :: term() def suggested_definition_name(report) do get_in(report, [:site_info, :url]) || get_in(report, [:site_info, :title]) end + @spec maybe_put(term(), term(), term()) :: term() def maybe_put(map, _key, nil), do: map def maybe_put(map, key, value), do: Map.put(map, key, value) + @spec allow_repo_sandbox(term()) :: term() def allow_repo_sandbox(pid) when is_pid(pid) do if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do try do @@ -241,8 +303,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do end end + @spec translate_phase(term()) :: term() def translate_phase(other), do: other - defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + defp present?(value), do: value not in [nil, ""] end diff --git a/lib/bds/desktop/shell_live/import_editor/conflict_resolution.ex b/lib/bds/desktop/shell_live/import_editor/conflict_resolution.ex index 7c8c2a3..1d46296 100644 --- a/lib/bds/desktop/shell_live/import_editor/conflict_resolution.ex +++ b/lib/bds/desktop/shell_live/import_editor/conflict_resolution.ex @@ -3,18 +3,27 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ConflictResolution do alias BDS.ImportDefinitions - def change_conflict_resolution(socket, %{"item_type" => item_type, "item_name" => item_name, "resolution" => resolution}, reload) do + @spec change_conflict_resolution(term(), term(), term()) :: term() + def change_conflict_resolution( + socket, + %{"item_type" => item_type, "item_name" => item_name, "resolution" => resolution}, + reload + ) do with %{id: definition_id} <- socket.assigns.current_tab, %{} = definition <- ImportDefinitions.get_definition(definition_id), %{} = report <- ImportDefinitions.decode_analysis_result(definition), updated_report <- update_conflict_resolution(report, item_type, item_name, resolution), - {:ok, _definition} <- ImportDefinitions.update_definition(definition_id, %{last_analysis_result: updated_report}) do + {:ok, _definition} <- + ImportDefinitions.update_definition(definition_id, %{ + last_analysis_result: updated_report + }) do reload.(socket, socket.assigns.workbench) else _other -> reload.(socket, socket.assigns.workbench) end end + @spec update_conflict_resolution(term(), term(), term(), term()) :: term() def update_conflict_resolution(report, item_type, item_name, resolution) do report |> update_in([:conflicts], fn conflicts -> @@ -30,10 +39,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ConflictResolution do |> update_in([:details], &update_conflict_bucket(&1, item_type, item_name, resolution)) end + @spec update_conflict_bucket(term(), term(), term(), term()) :: term() def update_conflict_bucket(nil, _item_type, _item_name, _resolution), do: nil def update_conflict_bucket(buckets, item_type, item_name, resolution) do - bucket_key = if(item_type == "page", do: :pages, else: if(item_type == "media", do: :media, else: :posts)) + bucket_key = + if(item_type == "page", + do: :pages, + else: if(item_type == "media", do: :media, else: :posts) + ) update_in(buckets, [bucket_key], fn items -> Enum.map(items || [], fn item -> diff --git a/lib/bds/desktop/shell_live/import_editor/progress_tracking.ex b/lib/bds/desktop/shell_live/import_editor/progress_tracking.ex index 3e9ca08..6f9d739 100644 --- a/lib/bds/desktop/shell_live/import_editor/progress_tracking.ex +++ b/lib/bds/desktop/shell_live/import_editor/progress_tracking.ex @@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do alias BDS.Desktop.ShellData alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState + @spec execute_import(term(), term(), term()) :: term() def execute_import(socket, reload, _append_output) do with %{id: definition_id} <- socket.assigns.current_tab, %{} = definition <- ImportDefinitions.get_definition(definition_id), @@ -24,7 +25,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do uploads_folder_path: definition.uploads_folder_path, default_author: default_author, on_progress: fn phase, current, total, detail -> - send(live_view_pid, {:import_execution_progress, definition_id, phase, current, total, detail}) + send( + live_view_pid, + {:import_execution_progress, definition_id, phase, current, total, detail} + ) end ) end) @@ -50,7 +54,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do ref: task.ref }) ) - |> Phoenix.Component.assign(:import_editor_execution_task_refs, Map.put(socket.assigns.import_editor_execution_task_refs, task.ref, definition_id)) + |> Phoenix.Component.assign( + :import_editor_execution_task_refs, + Map.put(socket.assigns.import_editor_execution_task_refs, task.ref, definition_id) + ) |> reload.(socket.assigns.workbench) end else @@ -58,6 +65,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do end end + @spec note_execution_progress(term(), term(), term(), term(), term(), term(), term()) :: term() def note_execution_progress(socket, definition_id, phase, current, total, detail, reload) do {detail_text, eta} = decompose_progress_detail(detail) translated_phase = translate_execution_phase(phase) @@ -65,30 +73,44 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do socket |> Phoenix.Component.assign( :import_editor_execution_states, - Map.update(socket.assigns.import_editor_execution_states, definition_id, default_execution_state(), fn state -> - state - |> Map.put(:is_executing, true) - |> Map.put(:phase, translated_phase) - |> Map.put(:current, current) - |> Map.put(:total, total) - |> Map.put(:detail, detail_text) - |> Map.put(:eta, eta) - end) + Map.update( + socket.assigns.import_editor_execution_states, + definition_id, + default_execution_state(), + fn state -> + state + |> Map.put(:is_executing, true) + |> Map.put(:phase, translated_phase) + |> Map.put(:current, current) + |> Map.put(:total, total) + |> Map.put(:detail, detail_text) + |> Map.put(:eta, eta) + end + ) ) |> reload.(socket.assigns.workbench) end + @spec finish_execution(term(), term(), term(), term(), term()) :: term() def finish_execution(socket, ref, result, reload, append_output) do case Map.get(socket.assigns.import_editor_execution_task_refs, ref) do nil -> socket definition_id -> - previous_state = Map.get(socket.assigns.import_editor_execution_states, definition_id, default_execution_state()) + previous_state = + Map.get( + socket.assigns.import_editor_execution_states, + definition_id, + default_execution_state() + ) socket = socket - |> Phoenix.Component.assign(:import_editor_execution_task_refs, Map.delete(socket.assigns.import_editor_execution_task_refs, ref)) + |> Phoenix.Component.assign( + :import_editor_execution_task_refs, + Map.delete(socket.assigns.import_editor_execution_task_refs, ref) + ) case result do {:ok, execution_result} -> @@ -106,7 +128,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do ref: nil }) ) - |> append_output.(translated("activity.import"), translated("importAnalysis.importComplete", %{count: previous_state.count}), nil, "info") + |> append_output.( + translated("activity.import"), + translated("importAnalysis.importComplete", %{count: previous_state.count}), + nil, + "info" + ) |> reload.(socket.assigns.workbench) {:error, %{message: message}} -> @@ -144,7 +171,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do end end - def handle_task_down(socket, kind, ref, reason, reload, append_output) when reason not in [:normal, :shutdown] do + @spec handle_task_down(term(), term(), term(), term(), term(), term()) :: term() + def handle_task_down(socket, kind, ref, reason, reload, append_output) + when reason not in [:normal, :shutdown] do message = inspect(reason) case kind do @@ -157,10 +186,18 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do socket definition_id -> - previous_state = Map.get(socket.assigns.import_editor_execution_states, definition_id, default_execution_state()) + previous_state = + Map.get( + socket.assigns.import_editor_execution_states, + definition_id, + default_execution_state() + ) socket - |> Phoenix.Component.assign(:import_editor_execution_task_refs, Map.delete(socket.assigns.import_editor_execution_task_refs, ref)) + |> Phoenix.Component.assign( + :import_editor_execution_task_refs, + Map.delete(socket.assigns.import_editor_execution_task_refs, ref) + ) |> Phoenix.Component.assign( :import_editor_execution_states, Map.put(socket.assigns.import_editor_execution_states, definition_id, %{ @@ -177,8 +214,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do end end + @spec handle_task_down(term(), term(), term(), term(), term(), term()) :: term() def handle_task_down(socket, _kind, _ref, _reason, _reload, _append_output), do: socket + @spec default_execution_state() :: term() def default_execution_state do %{ is_executing: false, @@ -195,6 +234,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do } end + @spec execution_progress_width(term()) :: term() def execution_progress_width(state) do current = Map.get(state, :current, 0) total = Map.get(state, :total, 0) @@ -205,25 +245,36 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do end end + @spec decompose_progress_detail(term()) :: term() def decompose_progress_detail(%{detail: detail, eta: eta}), do: {to_string_or_nil(detail), eta} - def decompose_progress_detail(detail) when is_binary(detail) or is_nil(detail), do: {detail, nil} + + def decompose_progress_detail(detail) when is_binary(detail) or is_nil(detail), + do: {detail, nil} + def decompose_progress_detail(detail), do: {to_string_or_nil(detail), nil} + @spec to_string_or_nil(term()) :: term() def to_string_or_nil(nil), do: nil def to_string_or_nil(value) when is_binary(value), do: value def to_string_or_nil(value), do: inspect(value) + @spec format_eta(term()) :: term() def format_eta(nil), do: nil def format_eta(ms) when is_integer(ms) and ms >= 0 do seconds = div(ms, 1000) if seconds < 60 do - translated("importAnalysis.eta", %{value: translated("importAnalysis.etaSeconds", %{count: seconds})}) + translated("importAnalysis.eta", %{ + value: translated("importAnalysis.etaSeconds", %{count: seconds}) + }) else m = div(seconds, 60) s = rem(seconds, 60) - translated("importAnalysis.eta", %{value: translated("importAnalysis.etaMinutes", %{minutes: m, seconds: s})}) + + translated("importAnalysis.eta", %{ + value: translated("importAnalysis.etaMinutes", %{minutes: m, seconds: s}) + }) end end @@ -240,7 +291,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do end end + @spec translate_execution_phase(term()) :: term() def translate_execution_phase(other), do: other - defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) end diff --git a/lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex b/lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex index 3a16b62..9032ca9 100644 --- a/lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex +++ b/lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex @@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do alias BDS.{AI, ImportDefinitions, Metadata, Tags} alias BDS.Desktop.ShellData + @spec start_taxonomy_edit(term(), term(), term()) :: term() def start_taxonomy_edit( socket, %{"type" => type, "name" => name, "mapped_to" => mapped_to}, @@ -25,6 +26,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do end end + @spec cancel_taxonomy_edit(term(), term()) :: term() def cancel_taxonomy_edit(socket, reload) do with %{id: definition_id} <- socket.assigns.current_tab do socket @@ -38,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do end end + @spec save_taxonomy_edit(term(), term(), term()) :: term() def save_taxonomy_edit( socket, %{"type" => type, "name" => name, "mapped_to" => mapped_to}, @@ -68,10 +71,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do end end + @spec clear_taxonomy_mapping(term(), term(), term()) :: term() def clear_taxonomy_mapping(socket, %{"type" => type, "name" => name}, reload) do save_taxonomy_edit(socket, %{"type" => type, "name" => name, "mapped_to" => ""}, reload) end + @spec analyze_taxonomy_ai(term(), term(), term()) :: term() def analyze_taxonomy_ai(socket, reload, append_output) do with %{id: definition_id} <- socket.assigns.current_tab, %{} = definition <- ImportDefinitions.get_definition(definition_id), @@ -142,6 +147,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do end end + @spec update_taxonomy_mapping(term(), term(), term(), term()) :: term() def update_taxonomy_mapping(report, type, name, mapped_to) do bucket_key = if(type == "categories", do: :categories, else: :tags) normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil() @@ -164,6 +170,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do ) end + @spec rebuild_taxonomy_stats(term()) :: term() def rebuild_taxonomy_stats(items) do %{ existing_count: Enum.count(items, & &1.exists_in_project), @@ -172,9 +179,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do } end + @spec stat_key(term()) :: term() def stat_key(:categories), do: :category_stats def stat_key(:tags), do: :tag_stats + @spec apply_taxonomy_mappings(term(), term()) :: term() def apply_taxonomy_mappings(report, analysis) do report |> update_in( @@ -198,6 +207,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do end) end + @spec apply_taxonomy_mapping_bucket(term(), term()) :: term() def apply_taxonomy_mapping_bucket(items, mappings) do Enum.map(items || [], fn item -> case Map.fetch(mappings, item.name) do @@ -207,6 +217,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do end) end + @spec existing_taxonomy_terms(term()) :: term() def existing_taxonomy_terms(project_id) do {:ok, metadata} = Metadata.get_project_metadata(project_id) @@ -216,6 +227,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do } end + @spec normalize_taxonomy_mapping_value(term(), term(), term()) :: term() def normalize_taxonomy_mapping_value(project_id, type, mapped_to) do normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil() @@ -231,6 +243,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do end end + @spec auto_mapped_count(term(), term()) :: term() def auto_mapped_count(previous_report, next_report) do previous_count = (Map.get(previous_report.items, :categories, []) ++ @@ -244,6 +257,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do max(next_count - previous_count, 0) end + @spec taxonomy_pill_class(term()) :: term() def taxonomy_pill_class(item) do cond do item.exists_in_project -> "import-taxonomy-pill exists" @@ -252,9 +266,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do end end + @spec taxonomy_item_editing?(term(), term(), term()) :: term() def taxonomy_item_editing?(%{type: type, name: name}, type, name), do: true def taxonomy_item_editing?(_edit, _type, _name), do: false + @spec taxonomy_mapping_tooltip(term()) :: term() def taxonomy_mapping_tooltip(item) do action = if present?(item.mapped_to), @@ -264,6 +280,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do translated("importAnalysis.mappingTooltip", %{action: action}) end + @spec maybe_put_option(term(), term(), term()) :: term() def maybe_put_option(opts, _key, nil), do: opts def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value) diff --git a/lib/bds/desktop/shell_live/layout.ex b/lib/bds/desktop/shell_live/layout.ex index 86b07ec..8cb9627 100644 --- a/lib/bds/desktop/shell_live/layout.ex +++ b/lib/bds/desktop/shell_live/layout.ex @@ -32,6 +32,7 @@ defmodule BDS.Desktop.ShellLive.Layout do end defp maybe_set_sidebar_width(workbench, nil), do: workbench + defp maybe_set_sidebar_width(workbench, width), do: Workbench.set_sidebar_width(workbench, parse_width(width)) diff --git a/lib/bds/desktop/shell_live/media_editor.ex b/lib/bds/desktop/shell_live/media_editor.ex index a3493b0..6290908 100644 --- a/lib/bds/desktop/shell_live/media_editor.ex +++ b/lib/bds/desktop/shell_live/media_editor.ex @@ -13,14 +13,16 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do alias BDS.Repo alias BDS.UI.Workbench - embed_templates "media_editor_html/*" + embed_templates("media_editor_html/*") @post_picker_limit 10 + @spec assign_socket(term()) :: term() def assign_socket(socket) do assign(socket, :media_editor, build(socket.assigns)) end + @spec update(term(), term(), term()) :: term() def update(socket, params, reload) do case socket.assigns.current_tab do %{type: :media, id: media_id} -> @@ -38,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end + @spec persist_socket(term(), term(), term(), term()) :: term() def persist_socket(socket, media_id, reload, append_output) do case Media.get_media(media_id) do nil -> @@ -52,9 +55,18 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do socket |> assign(:workbench, workbench) - |> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id)) - |> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved)) - |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media))) + |> assign( + :media_editor_drafts, + Map.delete(socket.assigns.media_editor_drafts, media_id) + ) + |> assign( + :media_editor_save_states, + Map.put(socket.assigns.media_editor_save_states, media_id, :saved) + ) + |> assign( + :tab_meta, + Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media)) + ) |> reload.(workbench) {:error, reason} -> @@ -65,14 +77,19 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end + @spec toggle_quick_actions(term(), term(), term()) :: term() def toggle_quick_actions(socket, media_id, reload) do workbench = socket.assigns.workbench socket - |> assign(:media_editor_quick_actions_open, Map.update(socket.assigns.media_editor_quick_actions_open, media_id, true, &(!&1))) + |> assign( + :media_editor_quick_actions_open, + Map.update(socket.assigns.media_editor_quick_actions_open, media_id, true, &(!&1)) + ) |> reload.(workbench) end + @spec replace_file(term(), term(), term(), term()) :: term() def replace_file(socket, media_id, reload, append_output) do case FilePicker.choose_file(translated("Replace Media File")) do {:ok, source_path} -> @@ -82,9 +99,18 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do socket |> assign(:workbench, workbench) - |> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id)) - |> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved)) - |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media))) + |> assign( + :media_editor_drafts, + Map.delete(socket.assigns.media_editor_drafts, media_id) + ) + |> assign( + :media_editor_save_states, + Map.put(socket.assigns.media_editor_save_states, media_id, :saved) + ) + |> assign( + :tab_meta, + Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media)) + ) |> reload.(workbench) {:ok, nil} -> @@ -106,10 +132,16 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end + @spec detect_language(term(), term(), term(), term()) :: term() def detect_language(socket, media_id, reload, append_output) do if Map.get(socket.assigns, :offline_mode, true) do socket - |> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") + |> append_output.( + translated("Detect Language"), + translated("Automatic AI actions stay gated by airplane mode."), + nil, + "info" + ) |> reload.(socket.assigns.workbench) else case Media.get_media(media_id) do @@ -118,15 +150,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do %MediaRecord{} = media -> draft = current_draft(socket.assigns, media) - text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "alt", ""), Map.get(draft, "caption", "")], "\n\n") + + text = + Enum.join( + [ + Map.get(draft, "title", ""), + Map.get(draft, "alt", ""), + Map.get(draft, "caption", "") + ], + "\n\n" + ) case AI.detect_language(text) do - {:ok, %{language_code: language_code}} when is_binary(language_code) and language_code != "" -> + {:ok, %{language_code: language_code}} + when is_binary(language_code) and language_code != "" -> normalized = normalize_language(language_code) case Media.update_media(media.id, %{language: normalized}) do {:ok, updated_media} -> - updated_draft = Map.put(current_draft(socket.assigns, media), "language", normalized) + updated_draft = + Map.put(current_draft(socket.assigns, media), "language", normalized) socket |> reconcile_draft(updated_media, updated_draft) @@ -145,17 +188,28 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do _other -> socket - |> append_output.(translated("Detect Language"), translated("Language detection failed."), nil, "error") + |> append_output.( + translated("Detect Language"), + translated("Language detection failed."), + nil, + "error" + ) |> reload.(socket.assigns.workbench) end end end end + @spec translate(term(), term(), term(), term(), term()) :: term() def translate(socket, media_id, language, reload, append_output) do if Map.get(socket.assigns, :offline_mode, true) do socket - |> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") + |> append_output.( + translated("Translate"), + translated("Automatic AI actions stay gated by airplane mode."), + nil, + "info" + ) |> reload.(socket.assigns.workbench) else normalized_language = normalize_language(language) @@ -165,8 +219,14 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do case Media.upsert_media_translation(media_id, normalized_language, translation) do {:ok, _saved_translation} -> socket - |> assign(:media_editor_quick_actions_open, Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false)) - |> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) + |> assign( + :media_editor_quick_actions_open, + Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false) + ) + |> assign( + :media_editor_translation_forms, + Map.delete(socket.assigns.media_editor_translation_forms, media_id) + ) |> reload.(socket.assigns.workbench) {:error, reason} -> @@ -183,6 +243,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end + @spec apply_ai_suggestions(term(), term(), term(), term(), term()) :: term() def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do try do case Media.get_media(media_id) do @@ -213,6 +274,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end + @spec delete_socket(term(), term(), term(), term()) :: term() def delete_socket(socket, media_id, reload, append_output) do case Media.delete_media(media_id) do {:ok, :deleted} -> @@ -223,11 +285,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do |> assign(:shell_overlay, nil) |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id})) |> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id)) - |> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id)) - |> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id)) - |> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id)) - |> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id)) - |> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) + |> assign( + :media_editor_quick_actions_open, + Map.delete(socket.assigns.media_editor_quick_actions_open, media_id) + ) + |> assign( + :media_editor_post_pickers_open, + Map.delete(socket.assigns.media_editor_post_pickers_open, media_id) + ) + |> assign( + :media_editor_post_picker_queries, + Map.delete(socket.assigns.media_editor_post_picker_queries, media_id) + ) + |> assign( + :media_editor_save_states, + Map.delete(socket.assigns.media_editor_save_states, media_id) + ) + |> assign( + :media_editor_translation_forms, + Map.delete(socket.assigns.media_editor_translation_forms, media_id) + ) |> reload.(workbench) {:error, reason} -> @@ -237,28 +314,43 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end + @spec toggle_post_picker(term(), term(), term()) :: term() def toggle_post_picker(socket, media_id, reload) do workbench = socket.assigns.workbench socket - |> assign(:media_editor_post_pickers_open, Map.update(socket.assigns.media_editor_post_pickers_open, media_id, true, &(!&1))) + |> assign( + :media_editor_post_pickers_open, + Map.update(socket.assigns.media_editor_post_pickers_open, media_id, true, &(!&1)) + ) |> reload.(workbench) end + @spec set_post_picker_query(term(), term(), term(), term()) :: term() def set_post_picker_query(socket, media_id, query, reload) do workbench = socket.assigns.workbench socket - |> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, to_string(query || ""))) + |> assign( + :media_editor_post_picker_queries, + Map.put(socket.assigns.media_editor_post_picker_queries, media_id, to_string(query || "")) + ) |> reload.(workbench) end + @spec link_post(term(), term(), term(), term(), term()) :: term() def link_post(socket, media_id, post_id, reload, append_output) do case Media.link_media_to_post(media_id, post_id) do {:ok, _linked} -> socket - |> assign(:media_editor_post_pickers_open, Map.put(socket.assigns.media_editor_post_pickers_open, media_id, false)) - |> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, "")) + |> assign( + :media_editor_post_pickers_open, + Map.put(socket.assigns.media_editor_post_pickers_open, media_id, false) + ) + |> assign( + :media_editor_post_picker_queries, + Map.put(socket.assigns.media_editor_post_picker_queries, media_id, "") + ) |> reload.(socket.assigns.workbench) {:error, reason} -> @@ -268,6 +360,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end + @spec unlink_post(term(), term(), term(), term(), term()) :: term() def unlink_post(socket, media_id, post_id, reload, append_output) do case Media.unlink_media_from_post(media_id, post_id) do {:ok, _unlinked} -> @@ -280,6 +373,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end + @spec edit_translation(term(), term(), term(), term()) :: term() def edit_translation(socket, media_id, language, reload) do workbench = socket.assigns.workbench @@ -287,16 +381,20 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do form = %{ "language" => language, - "title" => translation && translation.title || "", - "alt" => translation && translation.alt || "", - "caption" => translation && translation.caption || "" + "title" => (translation && translation.title) || "", + "alt" => (translation && translation.alt) || "", + "caption" => (translation && translation.caption) || "" } socket - |> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form)) + |> assign( + :media_editor_translation_forms, + Map.put(socket.assigns.media_editor_translation_forms, media_id, form) + ) |> reload.(workbench) end + @spec update_translation(term(), term(), term(), term()) :: term() def update_translation(socket, media_id, params, reload) do workbench = socket.assigns.workbench @@ -308,10 +406,14 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do } socket - |> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form)) + |> assign( + :media_editor_translation_forms, + Map.put(socket.assigns.media_editor_translation_forms, media_id, form) + ) |> reload.(workbench) end + @spec save_translation(term(), term(), term(), term()) :: term() def save_translation(socket, media_id, reload, append_output) do case Map.get(socket.assigns.media_editor_translation_forms, media_id) do %{"language" => language} = form when language not in [nil, ""] -> @@ -322,7 +424,10 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do }) do {:ok, _translation} -> socket - |> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) + |> assign( + :media_editor_translation_forms, + Map.delete(socket.assigns.media_editor_translation_forms, media_id) + ) |> reload.(socket.assigns.workbench) {:error, reason} -> @@ -336,16 +441,23 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end + @spec refresh_translation(term(), term(), term(), term(), term()) :: term() def refresh_translation(socket, media_id, language, reload, append_output) do if Map.get(socket.assigns, :offline_mode, true) do socket - |> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") + |> append_output.( + translated("Translate"), + translated("Automatic AI actions stay gated by airplane mode."), + nil, + "info" + ) |> reload.(socket.assigns.workbench) else case AI.translate_media(media_id, normalize_language(language)) do {:ok, translation} -> case Media.upsert_media_translation(media_id, language, translation) do - {:ok, _saved_translation} -> socket |> reload.(socket.assigns.workbench) + {:ok, _saved_translation} -> + socket |> reload.(socket.assigns.workbench) {:error, reason} -> socket @@ -361,11 +473,15 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end + @spec delete_translation(term(), term(), term(), term(), term()) :: term() def delete_translation(socket, media_id, language, reload, append_output) do case Media.delete_media_translation(media_id, language) do {:ok, _deleted?} -> socket - |> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) + |> assign( + :media_editor_translation_forms, + Map.delete(socket.assigns.media_editor_translation_forms, media_id) + ) |> reload.(socket.assigns.workbench) {:error, reason} -> @@ -375,6 +491,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end + @spec build(term()) :: term() def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do case Media.get_media(media_id) do nil -> @@ -385,7 +502,9 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do translations = Media.list_media_translations(media.id) form = current_draft(assigns, media) picker_query = Map.get(assigns.media_editor_post_picker_queries, media.id, "") - {picker_results, picker_overflow_count} = post_picker_results(media, linked_posts, picker_query) + + {picker_results, picker_overflow_count} = + post_picker_results(media, linked_posts, picker_query) %{ id: media.id, @@ -416,20 +535,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do def build(_assigns), do: nil - def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + @spec translated(term(), term()) :: term() + def translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + @spec media_editor_save_state_label(term()) :: term() def media_editor_save_state_label(:dirty), do: translated("Unsaved") def media_editor_save_state_label(:saved), do: translated("Saved") def media_editor_save_state_label(_state), do: translated("Idle") + @spec language_label(term()) :: term() def language_label(code) do code |> to_string() |> String.upcase() end + @spec normalize_language(term()) :: term() def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase() + @spec persist(term(), term()) :: term() def persist(%MediaRecord{} = media, draft) do Media.update_media(media.id, %{ title: blank_to_nil(Map.get(draft, "title")), @@ -444,7 +569,11 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do defp reconcile_draft(socket, %MediaRecord{} = media, draft) do persisted = persisted_form(media) dirty? = draft != persisted - workbench = if dirty?, do: Workbench.mark_dirty(socket.assigns.workbench, :media, media.id), else: Workbench.clear_dirty(socket.assigns.workbench, :media, media.id) + + workbench = + if dirty?, + do: Workbench.mark_dirty(socket.assigns.workbench, :media, media.id), + else: Workbench.clear_dirty(socket.assigns.workbench, :media, media.id) drafts = if dirty? do @@ -456,8 +585,21 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do socket |> assign(:workbench, workbench) |> assign(:media_editor_drafts, drafts) - |> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media.id, if(dirty?, do: :dirty, else: :idle))) - |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media.id}, %{title: blank_to_nil(Map.get(draft, "title")) || display_title(media), subtitle: media.original_name || media.mime_type || ""})) + |> assign( + :media_editor_save_states, + Map.put( + socket.assigns.media_editor_save_states, + media.id, + if(dirty?, do: :dirty, else: :idle) + ) + ) + |> assign( + :tab_meta, + Map.put(socket.assigns.tab_meta, {:media, media.id}, %{ + title: blank_to_nil(Map.get(draft, "title")) || display_title(media), + subtitle: media.original_name || media.mime_type || "" + }) + ) end defp current_draft(assigns, %MediaRecord{} = media) do @@ -505,10 +647,15 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do from post in Post, where: post.project_id == ^media.project_id, order_by: [desc: post.updated_at, desc: post.created_at], - select: %{post_id: post.id, title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id)} + select: %{ + post_id: post.id, + title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id) + } ) |> Enum.reject(&MapSet.member?(linked_ids, &1.post_id)) - |> Enum.filter(fn post -> normalized_query == "" or String.contains?(String.downcase(post.title), normalized_query) end) + |> Enum.filter(fn post -> + normalized_query == "" or String.contains?(String.downcase(post.title), normalized_query) + end) {Enum.take(posts, @post_picker_limit), max(length(posts) - @post_picker_limit, 0)} end @@ -518,18 +665,28 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end defp preview_url(%MediaRecord{} = media) do - if image?(media), do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}", else: nil + if image?(media), + do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}", + else: nil end - defp image?(%MediaRecord{} = media), do: String.starts_with?(to_string(media.mime_type || ""), "image/") + defp image?(%MediaRecord{} = media), + do: String.starts_with?(to_string(media.mime_type || ""), "image/") - defp display_title(%MediaRecord{} = media), do: blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id + defp display_title(%MediaRecord{} = media), + do: blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id + + defp dimensions_label(%MediaRecord{width: width, height: height}) + when is_integer(width) and is_integer(height), do: "#{width} x #{height}" - defp dimensions_label(%MediaRecord{width: width, height: height}) when is_integer(width) and is_integer(height), do: "#{width} x #{height}" defp dimensions_label(_media), do: nil - defp format_file_size(size) when is_integer(size) and size >= 1_048_576, do: :erlang.float_to_binary(size / 1_048_576, decimals: 1) <> " MB" - defp format_file_size(size) when is_integer(size), do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB" + defp format_file_size(size) when is_integer(size) and size >= 1_048_576, + do: :erlang.float_to_binary(size / 1_048_576, decimals: 1) <> " MB" + + defp format_file_size(size) when is_integer(size), + do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB" + defp format_file_size(_size), do: "0.0 KB" defp detect_language_enabled?(form) do @@ -567,5 +724,6 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do end end - defp reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench) + defp reload_with_assigned_workbench(socket, reload), + do: reload.(socket, socket.assigns.workbench) end diff --git a/lib/bds/desktop/shell_live/menu_editor.ex b/lib/bds/desktop/shell_live/menu_editor.ex index b8a1208..05dd42a 100644 --- a/lib/bds/desktop/shell_live/menu_editor.ex +++ b/lib/bds/desktop/shell_live/menu_editor.ex @@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do use Phoenix.Component alias BDS.Desktop.ShellData + alias BDS.Desktop.ShellLive.MenuEditor.{ DraftManagement, PageCategory, @@ -12,8 +13,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do TreePredicates } - embed_templates "menu_editor_html/*" + embed_templates("menu_editor_html/*") + @spec assign_socket(term()) :: term() def assign_socket(socket) do case socket.assigns[:current_tab] do %{type: :menu_editor, id: tab_id} -> @@ -36,12 +38,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do end end + @spec select_item(term(), term(), term()) :: term() def select_item(socket, item_id, reload) do socket |> State.update_state(fn state -> %{state | selected_id: item_id} end) |> reload.(socket.assigns.workbench) end + @spec change_entry(term(), term(), term()) :: term() def change_entry(socket, params, reload) do query = Map.get(params, "query", "") @@ -50,6 +54,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do |> reload.(socket.assigns.workbench) end + @spec submit_entry(term(), term()) :: term() def submit_entry(socket, reload) do case DraftManagement.current_draft(socket.assigns) do %{type: :page} -> @@ -67,12 +72,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do end end + @spec cancel_entry(term(), term()) :: term() def cancel_entry(socket, reload) do socket |> State.update_state(&DraftManagement.cancel_draft/1) |> reload.(socket.assigns.workbench) end + @spec select_page(term(), term(), term()) :: term() def select_page(socket, post_id, reload) do case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do nil -> @@ -85,6 +92,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do end end + @spec select_category(term(), term(), term()) :: term() def select_category(socket, name, reload) do project_id = socket.assigns.projects.active_project_id @@ -99,6 +107,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do end end + @spec toolbar_action(term(), term(), term(), term()) :: term() def toolbar_action(socket, action, reload, append_output) do case action do "add-entry" -> @@ -144,12 +153,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do end end + @spec drop_item(term(), term(), term(), term(), term()) :: term() def drop_item(socket, drag_item_id, target_item_id, position, reload) do socket |> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position)) |> reload.(socket.assigns.workbench) end + @spec handle_keydown(term(), term(), term()) :: term() def handle_keydown(socket, "Escape", reload) do cancel_entry(socket, reload) end @@ -158,14 +169,16 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do reload.(socket, socket.assigns.workbench) end - attr :menu_editor, :map, required: true + attr(:menu_editor, :map, required: true) + @spec menu_editor(term()) :: term() def menu_editor(assigns) - attr :items, :list, required: true - attr :menu_editor, :map, required: true - attr :depth, :integer, required: true + attr(:items, :list, required: true) + attr(:menu_editor, :map, required: true) + attr(:depth, :integer, required: true) + @spec menu_tree_level(term()) :: term() def menu_tree_level(assigns) do ~H""" <%= for item <- @items do %> @@ -289,8 +302,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do """ end - attr :kind, :atom, required: true + attr(:kind, :atom, required: true) + @spec kind_icon(term()) :: term() def kind_icon(assigns) do ~H""" <%= case @kind do %> @@ -306,9 +320,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do """ end + @spec translated(term(), term()) :: term() def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + @spec row_label(term(), term()) :: term() def row_label(item, category_titles) do if item.kind == :category_archive do Map.get(category_titles || %{}, item.slug, item.label) @@ -317,6 +333,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do end end + @spec kind_label(term()) :: term() def kind_label(:home), do: translated("menuEditor.type.home") def kind_label(:page), do: translated("menuEditor.type.page") def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive") @@ -324,12 +341,17 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do defdelegate draft_item?(menu_editor, item_id), to: TreePredicates + @spec editing_title(term()) :: term() def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive") def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title") + @spec editing_hint(term()) :: term() def editing_hint(%{draft: %{type: :category}}), do: translated("menuEditor.categoryPicker.hint") def editing_hint(_menu_editor), do: translated("menuEditor.createHint") - def editing_placeholder(%{draft: %{type: :category}}), do: translated("menuEditor.newCategoryPlaceholder") + @spec editing_placeholder(term()) :: term() + def editing_placeholder(%{draft: %{type: :category}}), + do: translated("menuEditor.newCategoryPlaceholder") + def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder") end diff --git a/lib/bds/desktop/shell_live/menu_editor/draft_management.ex b/lib/bds/desktop/shell_live/menu_editor/draft_management.ex index f93f2e9..2cd0b48 100644 --- a/lib/bds/desktop/shell_live/menu_editor/draft_management.ex +++ b/lib/bds/desktop/shell_live/menu_editor/draft_management.ex @@ -6,8 +6,10 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do alias BDS.Desktop.ShellLive.MenuEditor.PageCategory alias BDS.Desktop.ShellLive.MenuEditor.TreeOps + @spec current_draft(term()) :: term() def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft) + @spec start_page_draft(term()) :: term() def start_page_draft(state) do item = %{ item_id: Ecto.UUID.generate(), @@ -29,6 +31,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do } end + @spec start_category_draft(term()) :: term() def start_category_draft(state) do item = %{ item_id: Ecto.UUID.generate(), @@ -50,6 +53,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do } end + @spec finalize_submenu_draft(term()) :: term() def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do label = if(String.trim(query) == "", @@ -69,12 +73,19 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do def finalize_submenu_draft(state), do: state + @spec assign_page_to_draft(term(), term()) :: term() def assign_page_to_draft(%{draft: %{item_id: item_id}} = state, post) do %{ state | items: TreeOps.update_item(state.items, item_id, fn item -> - %{item | kind: :page, label: post.title, slug: PageCategory.blank_to_nil(post.slug), children: []} + %{ + item + | kind: :page, + label: post.title, + slug: PageCategory.blank_to_nil(post.slug), + children: [] + } end), draft: nil } @@ -82,6 +93,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do def assign_page_to_draft(state, _post), do: state + @spec assign_category_to_draft(term(), term()) :: term() def assign_category_to_draft(%{draft: %{item_id: item_id}} = state, category) do label = PageCategory.blank_to_nil(category.title) || category.name @@ -97,6 +109,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do def assign_category_to_draft(state, _category), do: state + @spec cancel_draft(term()) :: term() def cancel_draft(%{draft: %{item_id: item_id}} = state) do items = TreeOps.remove_item(state.items, item_id) %{state | items: items, selected_id: TreeOps.first_item_id(items), draft: nil} @@ -104,6 +117,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do def cancel_draft(state), do: state + @spec confirm_category_draft(term(), term()) :: term() def confirm_category_draft(socket, update_state_fun) do project_id = socket.assigns.projects.active_project_id draft = current_draft(socket.assigns) @@ -117,8 +131,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do category = cond do - category != nil -> category - normalized == "" -> %{name: "", title: ""} + category != nil -> + category + + normalized == "" -> + %{name: "", title: ""} + true -> {:ok, _metadata} = Metadata.add_category(project_id, normalized) %{name: normalized, title: normalized} diff --git a/lib/bds/desktop/shell_live/menu_editor/page_category.ex b/lib/bds/desktop/shell_live/menu_editor/page_category.ex index d38d575..fcc04f2 100644 --- a/lib/bds/desktop/shell_live/menu_editor/page_category.ex +++ b/lib/bds/desktop/shell_live/menu_editor/page_category.ex @@ -6,19 +6,26 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do alias BDS.{Metadata, Repo} alias BDS.Posts.Post + @spec page_posts(term()) :: term() def page_posts(nil), do: [] def page_posts(project_id) do - Repo.all(from post in Post, where: post.project_id == ^project_id, order_by: [asc: post.title, asc: post.slug]) + Repo.all( + from post in Post, + where: post.project_id == ^project_id, + order_by: [asc: post.title, asc: post.slug] + ) |> Enum.filter(&("page" in (&1.categories || []))) end + @spec page_post(term(), term()) :: term() def page_post(nil, _post_id), do: nil def page_post(project_id, post_id) do Enum.find(page_posts(project_id), &(&1.id == post_id)) end + @spec filter_page_posts(term(), term()) :: term() def filter_page_posts(posts, query) do normalized = query |> to_string() |> String.trim() |> String.downcase() @@ -29,6 +36,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do end) end + @spec category_options(term()) :: term() def category_options(nil), do: [] def category_options(project_id) do @@ -40,6 +48,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do end) end + @spec filter_categories(term(), term()) :: term() def filter_categories(categories, query) do normalized = query |> to_string() |> String.trim() |> String.downcase() @@ -50,7 +59,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do end) end + @spec blank_to_nil(term()) :: term() def blank_to_nil(nil), do: nil + def blank_to_nil(value) do trimmed = String.trim(to_string(value)) if trimmed == "", do: nil, else: trimmed diff --git a/lib/bds/desktop/shell_live/menu_editor/state.ex b/lib/bds/desktop/shell_live/menu_editor/state.ex index 139bd1e..333697e 100644 --- a/lib/bds/desktop/shell_live/menu_editor/state.ex +++ b/lib/bds/desktop/shell_live/menu_editor/state.ex @@ -7,6 +7,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do alias BDS.Menu alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates} + @spec ensure_state(term()) :: term() def ensure_state(assigns) do project_id = assigns.projects.active_project_id @@ -16,11 +17,13 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do end end + @spec update_state(term(), term()) :: term() def update_state(socket, updater) do state = ensure_state(socket.assigns) assign(socket, :menu_editor_state, updater.(state)) end + @spec build(term(), term()) :: term() def build(_assigns, state) do categories = PageCategory.category_options(state.project_id) draft = state.draft @@ -35,7 +38,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do draft_query: draft_query, filtered_pages: if(match?(%{type: :page}, draft), - do: PageCategory.filter_page_posts(PageCategory.page_posts(state.project_id), draft_query), + do: + PageCategory.filter_page_posts(PageCategory.page_posts(state.project_id), draft_query), else: [] ), filtered_categories: @@ -53,6 +57,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do } end + @spec save(term(), term(), term()) :: term() def save(socket, reload, append_output) do state = socket.assigns.menu_editor_state @@ -60,12 +65,22 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do Menu.update_menu(state.project_id, Enum.map(state.items, &TreeOps.persisted_item/1)) socket - |> append_output.(translated("menuEditor.tabTitle"), translated("menuEditor.saved"), nil, "info") + |> append_output.( + translated("menuEditor.tabTitle"), + translated("menuEditor.saved"), + nil, + "info" + ) |> reload.(socket.assigns.workbench) end defp load_state(nil) do - %{project_id: nil, items: [TreeOps.home_item()], selected_id: TreeOps.home_item_id(), draft: nil} + %{ + project_id: nil, + items: [TreeOps.home_item()], + selected_id: TreeOps.home_item_id(), + draft: nil + } end defp load_state(project_id) do diff --git a/lib/bds/desktop/shell_live/menu_editor/tree_ops.ex b/lib/bds/desktop/shell_live/menu_editor/tree_ops.ex index 6919efd..61d023c 100644 --- a/lib/bds/desktop/shell_live/menu_editor/tree_ops.ex +++ b/lib/bds/desktop/shell_live/menu_editor/tree_ops.ex @@ -3,12 +3,15 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do @home_item_id "menu-home" + @spec home_item_id() :: term() def home_item_id, do: @home_item_id + @spec home_item() :: term() def home_item do %{item_id: @home_item_id, kind: :home, label: "Home", slug: nil, children: [], is_home: true} end + @spec ui_item(term()) :: term() def ui_item(%{kind: :home}), do: home_item() def ui_item(item) do @@ -24,25 +27,37 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do } end + @spec persisted_item(term()) :: term() def persisted_item(%{kind: :home}), do: %{kind: :home, label: "Home", slug: nil} def persisted_item(%{kind: :submenu} = item) do - %{kind: :submenu, label: item.label, slug: nil, children: Enum.map(item.children || [], &persisted_item/1)} + %{ + kind: :submenu, + label: item.label, + slug: nil, + children: Enum.map(item.children || [], &persisted_item/1) + } end def persisted_item(item) do %{kind: item.kind, label: item.label, slug: item.slug} end + @spec first_item_id(term()) :: term() def first_item_id([item | _rest]), do: item.item_id def first_item_id([]), do: nil + @spec insert_target(term(), term()) :: term() def insert_target(items, nil), do: {[], length(items)} def insert_target(items, selected_id) do case find_path(items, selected_id) do - nil -> {[], length(items)} - [] -> {[], length(items)} + nil -> + {[], length(items)} + + [] -> + {[], length(items)} + path -> case item_at_path(items, path) do %{kind: :submenu} -> {path, 0} @@ -51,9 +66,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do end end + @spec path_prefix?(term(), term()) :: term() def path_prefix?(prefix, path) when length(prefix) > length(path), do: false + @spec path_prefix?(term(), term()) :: term() def path_prefix?(prefix, path), do: Enum.take(path, length(prefix)) == prefix + @spec find_path(term(), term(), term()) :: term() def find_path(items, item_id, path \\ []) do Enum.find_value(Enum.with_index(items), fn {item, index} -> next_path = path ++ [index] @@ -71,6 +89,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do end) end + @spec item_at_path(term(), term()) :: term() def item_at_path(_items, []), do: nil def item_at_path(items, [index]) do @@ -84,6 +103,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do end end + @spec items_at_path(term(), term()) :: term() def items_at_path(items, []), do: items def items_at_path(items, [index | rest]) do @@ -93,6 +113,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do end end + @spec replace_items_at_path(term(), term(), term()) :: term() def replace_items_at_path(_items, [], replacement), do: replacement def replace_items_at_path(items, [index | rest], replacement) do @@ -101,6 +122,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do end) end + @spec update_item(term(), term(), term()) :: term() def update_item(items, item_id, updater) do Enum.map(items, fn item -> cond do @@ -111,6 +133,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do end) end + @spec insert_item(term(), term(), term(), term()) :: term() def insert_item(items, [], index, item) do List.insert_at(items, index, item) end @@ -121,10 +144,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do end) end + @spec remove_item(term(), term()) :: term() def remove_item(items, item_id) do remove_item_with_value(items, item_id) |> elem(0) end + @spec remove_item_with_value(term(), term()) :: term() def remove_item_with_value(items, item_id) do Enum.reduce_while(Enum.with_index(items), {items, nil}, fn {item, index}, _acc -> cond do @@ -135,7 +160,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do {next_children, removed_item} = remove_item_with_value(item.children, item_id) if removed_item do - {:halt, {List.replace_at(items, index, %{item | children: next_children}), removed_item}} + {:halt, + {List.replace_at(items, index, %{item | children: next_children}), removed_item}} else {:cont, {items, nil}} end @@ -146,16 +172,23 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do end) end + @spec append_child(term(), term(), term()) :: term() def append_child(items, parent_item_id, child) do update_item(items, parent_item_id, fn item -> %{item | children: (item.children || []) ++ [child]} end) end - def move_selected(%{selected_id: selected_id} = state, direction) when direction in [:up, :down] do + @spec move_selected(term(), term()) :: term() + def move_selected(%{selected_id: selected_id} = state, direction) + when direction in [:up, :down] do case find_path(state.items, selected_id) do - nil -> state - [] -> state + nil -> + state + + [] -> + state + path -> parent_path = Enum.drop(path, -1) index = List.last(path) @@ -175,10 +208,15 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do end end + @spec indent_selected(term()) :: term() def indent_selected(%{selected_id: selected_id} = state) do case find_path(state.items, selected_id) do - nil -> state - [] -> state + nil -> + state + + [] -> + state + path -> parent_path = Enum.drop(path, -1) index = List.last(path) @@ -193,7 +231,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do case item_at_path(state.items, previous_sibling_path) do %{kind: :submenu, item_id: sibling_id} -> case remove_item_with_value(state.items, selected_id) do - {_next_items, nil} -> state + {_next_items, nil} -> + state + {next_items, removed_item} -> %{ state @@ -208,18 +248,27 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do end end + @spec unindent_selected(term()) :: term() def unindent_selected(%{selected_id: selected_id} = state) do case find_path(state.items, selected_id) do - nil -> state - [] -> state - [_root_index] -> state + nil -> + state + + [] -> + state + + [_root_index] -> + state + path -> parent_path = Enum.drop(path, -1) parent_index = List.last(parent_path) grand_parent_path = Enum.drop(parent_path, -1) case remove_item_with_value(state.items, selected_id) do - {_next_items, nil} -> state + {_next_items, nil} -> + state + {next_items, removed_item} -> %{ state @@ -229,6 +278,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do end end + @spec delete_selected(term()) :: term() def delete_selected(%{selected_id: @home_item_id} = state), do: state def delete_selected(%{selected_id: selected_id} = state) do @@ -241,9 +291,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do state end - def drop_selected(state, drag_item_id, target_item_id, _position) when drag_item_id == target_item_id, - do: state + def drop_selected(state, drag_item_id, target_item_id, _position) + when drag_item_id == target_item_id, + do: state + @spec drop_selected(term(), term(), term(), term()) :: term() def drop_selected(state, drag_item_id, target_item_id, position) do drag_path = find_path(state.items, drag_item_id) target_path = find_path(state.items, target_item_id) @@ -275,7 +327,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do defp insert_dropped_item(state, next_items, dragged_item, target_path, "inside") do case item_at_path(next_items, target_path) do %{kind: :submenu} -> - %{state | items: insert_item(next_items, target_path, 0, dragged_item), selected_id: dragged_item.item_id} + %{ + state + | items: insert_item(next_items, target_path, 0, dragged_item), + selected_id: dragged_item.item_id + } _other -> state @@ -285,12 +341,22 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do defp insert_dropped_item(state, next_items, dragged_item, target_path, "before") do parent_path = Enum.drop(target_path, -1) index = List.last(target_path) - %{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id} + + %{ + state + | items: insert_item(next_items, parent_path, index, dragged_item), + selected_id: dragged_item.item_id + } end defp insert_dropped_item(state, next_items, dragged_item, target_path, _position) do parent_path = Enum.drop(target_path, -1) index = List.last(target_path) + 1 - %{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id} + + %{ + state + | items: insert_item(next_items, parent_path, index, dragged_item), + selected_id: dragged_item.item_id + } end end diff --git a/lib/bds/desktop/shell_live/menu_editor/tree_predicates.ex b/lib/bds/desktop/shell_live/menu_editor/tree_predicates.ex index cabf4a0..a8e64b6 100644 --- a/lib/bds/desktop/shell_live/menu_editor/tree_predicates.ex +++ b/lib/bds/desktop/shell_live/menu_editor/tree_predicates.ex @@ -3,6 +3,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do alias BDS.Desktop.ShellLive.MenuEditor.TreeOps + @spec can_move_up?(term(), term()) :: term() def can_move_up?(items, selected_id) do case TreeOps.find_path(items, selected_id) do [_parent, index] -> index > 0 @@ -12,9 +13,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do end end + @spec can_move_down?(term(), term()) :: term() def can_move_down?(items, selected_id) do case TreeOps.find_path(items, selected_id) do - nil -> false + nil -> + false + path -> parent_path = Enum.drop(path, -1) index = List.last(path) @@ -22,10 +26,15 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do end end + @spec can_indent?(term(), term()) :: term() def can_indent?(items, selected_id) do case TreeOps.find_path(items, selected_id) do - nil -> false - [] -> false + nil -> + false + + [] -> + false + [_index] = path -> index = List.last(path) index > 0 and match?(%{kind: :submenu}, TreeOps.item_at_path(items, [index - 1])) @@ -34,10 +43,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do index = List.last(path) index > 0 and - match?(%{kind: :submenu}, TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1])) + match?( + %{kind: :submenu}, + TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1]) + ) end end + @spec can_unindent?(term(), term()) :: term() def can_unindent?(items, selected_id) do case TreeOps.find_path(items, selected_id) do [_index] -> false @@ -46,9 +59,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do end end + @spec can_delete?(term()) :: term() def can_delete?(selected_id), do: is_binary(selected_id) and selected_id != TreeOps.home_item_id() + @spec draft_item?(term(), term()) :: term() def draft_item?(menu_editor, item_id) do match?(%{item_id: ^item_id}, menu_editor.draft) end diff --git a/lib/bds/desktop/shell_live/misc_editor.ex b/lib/bds/desktop/shell_live/misc_editor.ex index 3db5809..04b6946 100644 --- a/lib/bds/desktop/shell_live/misc_editor.ex +++ b/lib/bds/desktop/shell_live/misc_editor.ex @@ -20,10 +20,12 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do :git_diff ] + @spec assign_socket(term()) :: term() def assign_socket(socket) do assign(socket, :misc_editor, build(socket.assigns)) end + @spec rerun(term()) :: term() def rerun(socket) do case meta(socket.assigns) do %{action: action} when is_binary(action) -> @@ -37,6 +39,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end end + @spec apply_site_validation(term(), term()) :: term() def apply_site_validation(socket, append_output) do meta = meta(socket.assigns) payload = Map.get(meta, :payload, %{}) @@ -68,6 +71,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")} end + @spec toggle_duplicate(term(), term(), term()) :: term() def toggle_duplicate(socket, pair_id, reload) do selected_by_tab = Map.get(socket.assigns, :misc_editor_selected_pairs, %{}) current = Map.get(selected_by_tab, socket.assigns.current_tab.id, MapSet.new()) @@ -87,6 +91,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do |> reload.(socket.assigns.workbench) end + @spec dismiss_duplicate(term(), term(), term(), term(), term()) :: term() def dismiss_duplicate(socket, post_id_a, post_id_b, reload, append_output) do case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do {:ok, _saved_pair} -> @@ -109,6 +114,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end end + @spec dismiss_selected(term(), term(), term()) :: term() def dismiss_selected(socket, reload, append_output) do tab_id = socket.assigns.current_tab.id @@ -141,6 +147,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end end + @spec fix_translation_validation(term(), term()) :: term() def fix_translation_validation(socket, append_output) do report = socket.assigns @@ -166,6 +173,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")} end + @spec select_git_diff_file(term(), term()) :: term() def select_git_diff_file(socket, file_path) do assign( socket, @@ -178,6 +186,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do ) end + @spec metadata_diff_repair_request(term(), term(), term()) :: term() def metadata_diff_repair_request(socket, field, direction) do meta = meta(socket.assigns) payload = Map.get(meta, :payload, %{}) @@ -209,6 +218,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end end + @spec metadata_diff_orphan_import_request(term()) :: term() def metadata_diff_orphan_import_request(socket) do meta = meta(socket.assigns) payload = Map.get(meta, :payload, %{}) @@ -232,6 +242,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end end + @spec build(term()) :: term() def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do meta = meta(assigns) payload = Map.get(meta, :payload, %{}) @@ -245,11 +256,14 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end end + @spec build(term()) :: term() def build(_assigns), do: nil + @spec translated(term(), term()) :: term() def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + @spec misc_class(term()) :: term() def misc_class(:site_validation), do: "site-validation-view" def misc_class(:metadata_diff), do: "metadata-diff-view" def misc_class(:translation_validation), do: "translation-validation-view" @@ -257,10 +271,13 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do def misc_class(:git_diff), do: "git-diff-view" def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary) + @spec summary_items(term()) :: term() def summary_items(_misc), do: [] + @spec duplicate_checked?(term(), term()) :: term() def duplicate_checked?(misc, pair_id), do: MapSet.member?(misc.selected_pairs, pair_id) + @spec pair_id_from_pair(term()) :: term() def pair_id_from_pair(pair), do: pair_identity(pair) defp build_site_validation(meta, payload) do @@ -410,6 +427,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do } end + @spec translation_issue_label(term()) :: term() def translation_issue_label(issue) do case issue_value(issue, :issue) do "same-language-as-canonical" -> @@ -426,6 +444,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end end + @spec translation_issue_languages(term()) :: term() def translation_issue_languages(issue) do canonical_language = issue_value(issue, :canonical_language) translation_language = issue_value(issue, :translation_language) @@ -440,8 +459,10 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end end + @spec translation_issue_value(term(), term()) :: term() def translation_issue_value(issue, key), do: issue_value(issue, key) + @spec git_diff_language(term()) :: term() def git_diff_language(nil), do: "plaintext" def git_diff_language(file_path) do diff --git a/lib/bds/desktop/shell_live/overlay_components.ex b/lib/bds/desktop/shell_live/overlay_components.ex index 3366af9..e4f7f71 100644 --- a/lib/bds/desktop/shell_live/overlay_components.ex +++ b/lib/bds/desktop/shell_live/overlay_components.ex @@ -12,7 +12,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do alias BDS.Posts.{Post, PostMedia, Translation} alias BDS.Tags.Tag - embed_templates "overlay_html/*" + embed_templates("overlay_html/*") def context(assigns, tab_title, tab_subtitle) do project_id = assigns.projects.active_project_id @@ -23,7 +23,12 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do media = media(project_id) %{ - current_tab: %{type: current_tab.type, id: current_tab.id, title: tab_title, subtitle: tab_subtitle}, + current_tab: %{ + type: current_tab.type, + id: current_tab.id, + title: tab_title, + subtitle: tab_subtitle + }, current_post_language: source_language(current_tab, metadata), current_media_language: source_language(current_tab, metadata), posts: posts, @@ -59,7 +64,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do def markdown_link(text, url), do: "[#{text}](#{url})" - def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + def translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) def project_metadata(nil), do: %{main_language: "en", blog_languages: []} @@ -77,7 +83,15 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do from post in Post, where: post.project_id == ^project_id, order_by: [desc: post.updated_at, desc: post.created_at], - select: %{id: post.id, title: post.title, slug: post.slug, status: post.status, published_at: post.published_at, updated_at: post.updated_at, language: post.language} + select: %{ + id: post.id, + title: post.title, + slug: post.slug, + status: post.status, + published_at: post.published_at, + updated_at: post.updated_at, + language: post.language + } ) |> Enum.map(fn post -> %{ @@ -96,7 +110,14 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do from media in MediaRecord, where: media.project_id == ^project_id, order_by: [desc: media.updated_at, desc: media.created_at], - select: %{id: media.id, title: media.title, original_name: media.original_name, mime_type: media.mime_type, alt: media.alt, caption: media.caption} + select: %{ + id: media.id, + title: media.title, + original_name: media.original_name, + mime_type: media.mime_type, + alt: media.alt, + caption: media.caption + } ) |> Enum.map(fn media -> %{ @@ -149,7 +170,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do defp existing_translations(_tab), do: %{} defp blog_languages(metadata) do - ([metadata.main_language || "en"] ++ (metadata.blog_languages || []) ++ Enum.map(I18n.supported_languages(), & &1.code)) + ([metadata.main_language || "en"] ++ + (metadata.blog_languages || []) ++ Enum.map(I18n.supported_languages(), & &1.code)) |> Enum.reject(&is_nil/1) |> Enum.uniq() end @@ -193,9 +215,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do case Posts.get_post(post_id) do %Post{} = post -> [ - %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: post.title || title, suggested_value: refine_title(post.title || title), locked: false}, - %{key: "excerpt", label: ShellData.translate("Excerpt", %{}, page_language), current_value: post.excerpt || subtitle, suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), locked: false}, - %{key: "slug", label: ShellData.translate("Slug", %{}, page_language), current_value: post.slug || slugify(post.title || title), suggested_value: refine_slug(post.slug || slugify(post.title || title)), locked: post.status == :published} + %{ + key: "title", + label: ShellData.translate("Title", %{}, page_language), + current_value: post.title || title, + suggested_value: refine_title(post.title || title), + locked: false + }, + %{ + key: "excerpt", + label: ShellData.translate("Excerpt", %{}, page_language), + current_value: post.excerpt || subtitle, + suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), + locked: false + }, + %{ + key: "slug", + label: ShellData.translate("Slug", %{}, page_language), + current_value: post.slug || slugify(post.title || title), + suggested_value: refine_slug(post.slug || slugify(post.title || title)), + locked: post.status == :published + } ] _other -> @@ -209,9 +249,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do case Media.get_media(media_id) do %MediaRecord{} = media -> [ - %{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: media.title || title, suggested_value: refine_title(media.title || title), locked: false}, - %{key: "alt", label: ShellData.translate("Alt Text", %{}, page_language), current_value: media.alt || "", suggested_value: media.alt || title, locked: false}, - %{key: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", suggested_value: refine_excerpt(title, media.caption || title), locked: false} + %{ + key: "title", + label: ShellData.translate("Title", %{}, page_language), + current_value: media.title || title, + suggested_value: refine_title(media.title || title), + locked: false + }, + %{ + key: "alt", + label: ShellData.translate("Alt Text", %{}, page_language), + current_value: media.alt || "", + suggested_value: media.alt || title, + locked: false + }, + %{ + key: "caption", + label: ShellData.translate("Caption", %{}, page_language), + current_value: media.caption || "", + suggested_value: refine_excerpt(title, media.caption || title), + locked: false + } ] _other -> @@ -248,7 +306,13 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do reference_list: reference_list } rescue - _error -> %{title: ShellData.translate("Delete Media", %{}, page_language), entity_name: media_id, entity_type: "media", reference_list: []} + _error -> + %{ + title: ShellData.translate("Delete Media", %{}, page_language), + entity_name: media_id, + entity_type: "media", + reference_list: [] + } end defp delete_details(%{type: :tags}, page_language) do @@ -263,16 +327,33 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do reference_list: [] } rescue - _error -> %{title: ShellData.translate("Delete Tag", %{}, page_language), entity_name: "tag", entity_type: "tag", reference_list: []} + _error -> + %{ + title: ShellData.translate("Delete Tag", %{}, page_language), + entity_name: "tag", + entity_type: "tag", + reference_list: [] + } end defp delete_details(_tab, page_language) do - %{title: ShellData.translate("Delete", %{}, page_language), entity_name: "", entity_type: "item", reference_list: []} + %{ + title: ShellData.translate("Delete", %{}, page_language), + entity_name: "", + entity_type: "item", + reference_list: [] + } end defp merge_details(project_id, page_language) do tags = - Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name], limit: 3, select: tag.name) + Repo.all( + from tag in Tag, + where: tag.project_id == ^project_id, + order_by: [asc: tag.name], + limit: 3, + select: tag.name + ) target = List.first(tags) || "tag" @@ -283,7 +364,13 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do message: ShellData.translate("Cannot be undone.", %{}, page_language) } rescue - _error -> %{target: "tag", count: 1, title: ShellData.translate("Merge Tags", %{}, page_language), message: ShellData.translate("Cannot be undone.", %{}, page_language)} + _error -> + %{ + target: "tag", + count: 1, + title: ShellData.translate("Merge Tags", %{}, page_language), + message: ShellData.translate("Cannot be undone.", %{}, page_language) + } end defp canonical_post_url(post) do @@ -302,7 +389,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do if base == "", do: "#{title} overview", else: base <> "." end - defp refine_slug(slug), do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated") + defp refine_slug(slug), + do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated") defp slugify(value) do value diff --git a/lib/bds/desktop/shell_live/panel_renderer.ex b/lib/bds/desktop/shell_live/panel_renderer.ex index cfca6f8..a16e874 100644 --- a/lib/bds/desktop/shell_live/panel_renderer.ex +++ b/lib/bds/desktop/shell_live/panel_renderer.ex @@ -210,8 +210,15 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do defp related_posts(links, key) do Enum.map(links, fn link -> case Posts.get_post(Map.fetch!(link, key)) do - %Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id} - _other -> nil + %Post{} = post -> + %{ + id: post.id, + title: post.title || post.slug || post.id, + text: link.link_text || post.slug || post.id + } + + _other -> + nil end end) |> Enum.reject(&is_nil/1) @@ -232,15 +239,22 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do defp git_history_target(%{type: :post, id: post_id}) do case Posts.get_post(post_id) do - %Post{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path} - _other -> nil + %Post{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> + {project_id, file_path} + + _other -> + nil end end defp git_history_target(%{type: :media, id: media_id}) do case Media.get_media(media_id) do - %MediaRecord{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path} - _other -> nil + %MediaRecord{project_id: project_id, file_path: file_path} + when file_path not in [nil, ""] -> + {project_id, file_path} + + _other -> + nil end end @@ -287,5 +301,6 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do defp present?(value), do: value not in [nil, ""] - defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) end diff --git a/lib/bds/desktop/shell_live/post_editor.ex b/lib/bds/desktop/shell_live/post_editor.ex index ec94ff8..aebf950 100644 --- a/lib/bds/desktop/shell_live/post_editor.ex +++ b/lib/bds/desktop/shell_live/post_editor.ex @@ -74,13 +74,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do defdelegate tag_chip_style(color), to: ListValues - embed_templates "post_editor_html/*" + embed_templates("post_editor_html/*") + @spec assign_socket(term()) :: term() def assign_socket(socket) do - assigns = Map.put(socket.assigns, :project_metadata, project_metadata(socket.assigns.projects.active_project_id)) + assigns = + Map.put( + socket.assigns, + :project_metadata, + project_metadata(socket.assigns.projects.active_project_id) + ) + assign(socket, :post_editor, build(assigns)) end + @spec update(term(), term(), term()) :: term() def update(socket, params, reload) do case socket.assigns.current_tab do %{type: :post, id: post_id} -> @@ -91,7 +99,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do %Post{} = post -> metadata = project_metadata(post.project_id) canonical_language = canonical_language(post, metadata) - current_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + + current_language = + Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + requested_language = normalize_language(Map.get(params, "language"), current_language) next_language = @@ -117,6 +128,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do end end + @spec persist_socket(term(), term(), term(), term(), term()) :: term() def persist_socket(socket, post_id, action, reload, append_output) do case Posts.get_post(post_id) do nil -> @@ -125,7 +137,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do %Post{} = post -> metadata = project_metadata(post.project_id) canonical_language = canonical_language(post, metadata) - active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + + active_language = + Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + draft = current_draft(socket.assigns, post, metadata, active_language) case persist(post, draft, active_language, metadata, action) do @@ -135,9 +150,30 @@ defmodule BDS.Desktop.ShellLive.PostEditor do socket |> assign(:workbench, workbench) - |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, normalized_form)) - |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, save_state_for_action(action))) - |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: record_title(record, Posts.get_post!(post_id)), subtitle: Atom.to_string(record_status(record))})) + |> assign( + :post_editor_drafts, + put_nested_map( + socket.assigns.post_editor_drafts, + post_id, + active_language, + normalized_form + ) + ) + |> assign( + :post_editor_save_states, + Map.put( + socket.assigns.post_editor_save_states, + post_id, + save_state_for_action(action) + ) + ) + |> assign( + :tab_meta, + Map.put(socket.assigns.tab_meta, {:post, post_id}, %{ + title: record_title(record, Posts.get_post!(post_id)), + subtitle: Atom.to_string(record_status(record)) + }) + ) |> reload.(workbench) {:error, reason} -> @@ -148,6 +184,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do end end + @spec discard_socket(term(), term(), term(), term()) :: term() def discard_socket(socket, post_id, reload, append_output) do case Posts.get_post(post_id) do nil -> @@ -156,7 +193,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do %Post{} = post -> metadata = project_metadata(post.project_id) canonical_language = canonical_language(post, metadata) - active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + + active_language = + Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) case discard(post, active_language, metadata) do {:ok, restored_post} -> @@ -164,9 +203,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do socket |> assign(:workbench, workbench) - |> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, active_language)) - |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :discarded)) - |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: restored_post.title || restored_post.slug || restored_post.id, subtitle: Atom.to_string(restored_post.status || :draft)})) + |> assign( + :post_editor_drafts, + delete_nested_map(socket.assigns.post_editor_drafts, post_id, active_language) + ) + |> assign( + :post_editor_save_states, + Map.put(socket.assigns.post_editor_save_states, post_id, :discarded) + ) + |> assign( + :tab_meta, + Map.put(socket.assigns.tab_meta, {:post, post_id}, %{ + title: restored_post.title || restored_post.slug || restored_post.id, + subtitle: Atom.to_string(restored_post.status || :draft) + }) + ) |> reload.(workbench) {:error, reason} -> @@ -177,6 +228,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do end end + @spec delete_socket(term(), term(), term(), term()) :: term() def delete_socket(socket, post_id, reload, append_output) do case Posts.delete_post(post_id) do {:ok, :deleted} -> @@ -185,13 +237,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor do socket |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id})) |> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id)) - |> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id)) - |> assign(:post_editor_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id)) - |> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id)) - |> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, post_id)) + |> assign( + :post_editor_active_languages, + Map.delete(socket.assigns.post_editor_active_languages, post_id) + ) + |> assign( + :post_editor_tag_queries, + Map.delete(socket.assigns.post_editor_tag_queries, post_id) + ) + |> assign( + :post_editor_category_queries, + Map.delete(socket.assigns.post_editor_category_queries, post_id) + ) + |> assign( + :post_editor_quick_actions_open, + Map.delete(socket.assigns.post_editor_quick_actions_open, post_id) + ) |> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id)) |> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id)) - |> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id)) + |> assign( + :post_editor_save_states, + Map.delete(socket.assigns.post_editor_save_states, post_id) + ) |> reload.(workbench) {:error, reason} -> @@ -201,6 +268,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do end end + @spec set_mode(term(), term(), term(), term()) :: term() def set_mode(socket, post_id, mode, reload) do workbench = socket.assigns.workbench normalized_mode = normalize_mode(mode) @@ -216,38 +284,67 @@ defmodule BDS.Desktop.ShellLive.PostEditor do end socket - |> assign(:post_editor_modes, Map.put(socket.assigns.post_editor_modes, post_id, normalized_mode)) + |> assign( + :post_editor_modes, + Map.put(socket.assigns.post_editor_modes, post_id, normalized_mode) + ) |> reload.(workbench) end + @spec toggle_section(term(), term(), term(), term()) :: term() def toggle_section(socket, post_id, section, reload) when section in [:metadata, :excerpt] do workbench = socket.assigns.workbench socket - |> assign(:post_editor_expanded, Map.put(socket.assigns.post_editor_expanded, post_id, toggled_sections(socket.assigns.post_editor_expanded, post_id, section))) + |> assign( + :post_editor_expanded, + Map.put( + socket.assigns.post_editor_expanded, + post_id, + toggled_sections(socket.assigns.post_editor_expanded, post_id, section) + ) + ) |> reload.(workbench) end + @spec select_language(term(), term(), term(), term()) :: term() def select_language(socket, post_id, language, reload) do workbench = socket.assigns.workbench socket - |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, normalize_language(language, language))) + |> assign( + :post_editor_active_languages, + Map.put( + socket.assigns.post_editor_active_languages, + post_id, + normalize_language(language, language) + ) + ) |> reload.(workbench) end + @spec toggle_quick_actions(term(), term(), term()) :: term() def toggle_quick_actions(socket, post_id, reload) do workbench = socket.assigns.workbench socket - |> assign(:post_editor_quick_actions_open, Map.update(socket.assigns.post_editor_quick_actions_open, post_id, true, &(!&1))) + |> assign( + :post_editor_quick_actions_open, + Map.update(socket.assigns.post_editor_quick_actions_open, post_id, true, &(!&1)) + ) |> reload.(workbench) end + @spec detect_language(term(), term(), term(), term()) :: term() def detect_language(socket, post_id, reload, append_output) do if Map.get(socket.assigns, :offline_mode, true) do socket - |> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") + |> append_output.( + translated("Detect Language"), + translated("Automatic AI actions stay gated by airplane mode."), + nil, + "info" + ) |> reload.(socket.assigns.workbench) else case Posts.get_post(post_id) do @@ -257,14 +354,24 @@ defmodule BDS.Desktop.ShellLive.PostEditor do %Post{} = post -> metadata = project_metadata(post.project_id) canonical_language = canonical_language(post, metadata) - active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + + active_language = + Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + draft = current_draft(socket.assigns, post, metadata, active_language) text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "content", "")], "\n\n") case AI.detect_language(text) do - {:ok, %{language_code: language_code}} when is_binary(language_code) and language_code != "" -> + {:ok, %{language_code: language_code}} + when is_binary(language_code) and language_code != "" -> socket - |> put_draft_field(post_id, post, active_language, "language", normalize_language(language_code, canonical_language)) + |> put_draft_field( + post_id, + post, + active_language, + "language", + normalize_language(language_code, canonical_language) + ) |> reload_with_assigned_workbench(reload) {:error, reason} -> @@ -274,17 +381,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor do _other -> socket - |> append_output.(translated("Detect Language"), translated("Language detection failed."), nil, "error") + |> append_output.( + translated("Detect Language"), + translated("Language detection failed."), + nil, + "error" + ) |> reload.(socket.assigns.workbench) end end end end + @spec translate(term(), term(), term(), term(), term()) :: term() def translate(socket, post_id, language, reload, append_output) do if Map.get(socket.assigns, :offline_mode, true) do socket - |> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") + |> append_output.( + translated("Translate"), + translated("Automatic AI actions stay gated by airplane mode."), + nil, + "info" + ) |> reload.(socket.assigns.workbench) else normalized_language = normalize_language(language, "") @@ -298,9 +416,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor do content: translation.content }) do socket - |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, normalized_language)) - |> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, normalized_language)) - |> assign(:post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false)) + |> assign( + :post_editor_active_languages, + Map.put(socket.assigns.post_editor_active_languages, post_id, normalized_language) + ) + |> assign( + :post_editor_drafts, + delete_nested_map(socket.assigns.post_editor_drafts, post_id, normalized_language) + ) + |> assign( + :post_editor_quick_actions_open, + Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false) + ) |> reload.(socket.assigns.workbench) else {:error, reason} -> @@ -317,6 +444,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do end end + @spec apply_ai_suggestions(term(), term(), term(), term(), term()) :: term() def apply_ai_suggestions(socket, post_id, fields, reload, append_output) do case Posts.get_post(post_id) do nil -> @@ -340,12 +468,30 @@ defmodule BDS.Desktop.ShellLive.PostEditor do case Posts.update_post(post_id, attrs) do {:ok, updated_post} -> metadata = project_metadata(updated_post.project_id) - active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language(updated_post, metadata)) + + active_language = + Map.get( + socket.assigns.post_editor_active_languages, + post_id, + canonical_language(updated_post, metadata) + ) + refreshed_form = persisted_form(updated_post, metadata, active_language) socket - |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, refreshed_form)) - |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) + |> assign( + :post_editor_drafts, + put_nested_map( + socket.assigns.post_editor_drafts, + post_id, + active_language, + refreshed_form + ) + ) + |> assign( + :post_editor_save_states, + Map.put(socket.assigns.post_editor_save_states, post_id, :dirty) + ) |> assign(:shell_overlay, nil) |> reload.(socket.assigns.workbench) @@ -358,6 +504,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do end end + @spec insert_content(term(), term(), term(), term()) :: term() def insert_content(socket, post_id, snippet, reload) do socket |> Phoenix.LiveView.push_event("post-editor-insert-content", %{id: post_id, content: snippet}) @@ -365,6 +512,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do |> reload.(socket.assigns.workbench) end + @spec add_list_value(term(), term(), term(), term(), term()) :: term() def add_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do case Posts.get_post(post_id) do nil -> @@ -373,7 +521,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do %Post{} = post -> metadata = project_metadata(post.project_id) canonical_language = canonical_language(post, metadata) - active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + + active_language = + Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + draft = current_draft(socket.assigns, post, metadata, active_language) normalized = normalize_list_entry(value) @@ -398,6 +549,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do end end + @spec remove_list_value(term(), term(), term(), term(), term()) :: term() def remove_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do case Posts.get_post(post_id) do nil -> @@ -406,9 +558,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor do %Post{} = post -> metadata = project_metadata(post.project_id) canonical_language = canonical_language(post, metadata) - active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + + active_language = + Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language) + draft = current_draft(socket.assigns, post, metadata, active_language) - updated = draft |> Map.get(field_key(kind), "") |> csv_to_list() |> Enum.reject(&(&1 == value)) |> Enum.join(", ") + + updated = + draft + |> Map.get(field_key(kind), "") + |> csv_to_list() + |> Enum.reject(&(&1 == value)) + |> Enum.join(", ") socket |> put_draft_field(post_id, post, active_language, field_key(kind), updated) @@ -416,6 +577,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do end end + @spec build(term()) :: term() def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do case Posts.get_post(post_id) do nil -> @@ -424,7 +586,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do %Post{} = post -> metadata = assigned_project_metadata(assigns) canonical_language = canonical_language(post, metadata) - active_language = Map.get(assigns.post_editor_active_languages, post.id, canonical_language) + + active_language = + Map.get(assigns.post_editor_active_languages, post.id, canonical_language) + translations = translations(post.id) persisted = DraftManagement.persisted_form(post, metadata, active_language, translations) @@ -453,13 +618,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do metadata_expanded: Map.get(expanded, :metadata, false), excerpt_expanded: Map.get(expanded, :excerpt, false), mode: Map.get(assigns.post_editor_modes, post.id, :markdown), - editing_canonical?: editing_canonical_language?(translations, active_language, canonical_language), + editing_canonical?: + editing_canonical_language?(translations, active_language, canonical_language), can_publish?: post.status == :draft, can_delete?: post.status == :published, has_published_version?: has_published_version?(post), discard_label: discard_label(post), discard_title: discard_title(post), - detect_language_enabled?: not blank?(Map.get(form, "title")) or not blank?(Map.get(form, "content")), + detect_language_enabled?: + not blank?(Map.get(form, "title")) or not blank?(Map.get(form, "content")), can_translate?: Enum.any?(languages(metadata), &(&1 != canonical_language)), languages: languages(metadata), form: form, @@ -469,16 +636,45 @@ defmodule BDS.Desktop.ShellLive.PostEditor do tag_values: tag_values(form), tag_chips: tag_chips(form, Tags.list_tags(post.project_id)), tag_query: query_value(assigns, :tags, post.id), - tag_query_addable?: query_addable?(query_value(assigns, :tags, post.id), tag_values(form), Tags.list_tags(post.project_id), fn option -> option.name end), + tag_query_addable?: + query_addable?( + query_value(assigns, :tags, post.id), + tag_values(form), + Tags.list_tags(post.project_id), + fn option -> option.name end + ), category_values: category_values(form), category_query: query_value(assigns, :categories, post.id), category_options: metadata.categories || [], - category_query_addable?: query_addable?(query_value(assigns, :categories, post.id), category_values(form), metadata.categories || [], & &1), - tag_suggestions: tag_suggestions(form, Tags.list_tags(post.project_id), query_value(assigns, :tags, post.id)), - category_suggestions: category_suggestions(form, metadata.categories || [], query_value(assigns, :categories, post.id)), + category_query_addable?: + query_addable?( + query_value(assigns, :categories, post.id), + category_values(form), + metadata.categories || [], + & &1 + ), + tag_suggestions: + tag_suggestions( + form, + Tags.list_tags(post.project_id), + query_value(assigns, :tags, post.id) + ), + category_suggestions: + category_suggestions( + form, + metadata.categories || [], + query_value(assigns, :categories, post.id) + ), gallery_count: gallery_count(form), - preview_url: preview_url(post, active_language, canonical_language, Map.get(assigns.post_editor_modes, post.id, :markdown)), - translation_flags: translation_flags(post, canonical_language, active_language, translations), + preview_url: + preview_url( + post, + active_language, + canonical_language, + Map.get(assigns.post_editor_modes, post.id, :markdown) + ), + translation_flags: + translation_flags(post, canonical_language, active_language, translations), linked_media: linked_media(post.id), post_links: post_links(post.id), footer: footer(post, current_translation, active_language, canonical_language) @@ -488,17 +684,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do def build(_assigns), do: nil + @spec post_status_label(term()) :: term() def post_status_label(status), do: ShellData.dashboard_status_label(status) + @spec post_editor_save_state_label(term()) :: term() def post_editor_save_state_label(:dirty), do: translated("Unsaved") def post_editor_save_state_label(:saved), do: translated("Saved") def post_editor_save_state_label(:published), do: translated("Published") def post_editor_save_state_label(:discarded), do: translated("Reverted") def post_editor_save_state_label(_state), do: translated("Idle") + @spec post_editor_mode_label(term()) :: term() def post_editor_mode_label(:markdown), do: translated("Markdown") def post_editor_mode_label(:preview), do: translated("Preview") + @spec translated(term(), term()) :: term() def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) diff --git a/lib/bds/desktop/shell_live/post_editor/draft_management.ex b/lib/bds/desktop/shell_live/post_editor/draft_management.ex index 2050a2a..f77e47e 100644 --- a/lib/bds/desktop/shell_live/post_editor/draft_management.ex +++ b/lib/bds/desktop/shell_live/post_editor/draft_management.ex @@ -8,11 +8,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do alias BDS.Desktop.ShellLive.PostEditor.PostMetadata alias BDS.UI.Workbench + @spec normalize_mode(term()) :: term() def normalize_mode(mode) when mode in [:markdown, :preview], do: mode + @spec normalize_mode(term()) :: term() def normalize_mode("visual"), do: :markdown def normalize_mode("preview"), do: :preview def normalize_mode(_mode), do: :markdown + @spec normalize_language(term(), term()) :: term() def normalize_language(value, fallback) do case value |> to_string() |> String.trim() do "" -> fallback @@ -20,6 +23,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do end end + @spec normalize_params(term(), term(), term()) :: term() def normalize_params(params, current_language, next_language) do %{ "title" => Map.get(params, "title", ""), @@ -28,12 +32,17 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do "tags" => Map.get(params, "tags", ""), "categories" => Map.get(params, "categories", ""), "author" => Map.get(params, "author", ""), - "language" => if(current_language == next_language, do: normalize_language(Map.get(params, "language"), current_language), else: next_language), + "language" => + if(current_language == next_language, + do: normalize_language(Map.get(params, "language"), current_language), + else: next_language + ), "do_not_translate" => truthy?(Map.get(params, "do_not_translate")), "template_slug" => Map.get(params, "template_slug", "") } end + @spec current_draft(term(), term(), term(), term()) :: term() def current_draft(assigns, %Post{} = post, metadata, active_language) do persisted = persisted_form(post, metadata, active_language) @@ -42,10 +51,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do |> Map.get(active_language, persisted) end + @spec persisted_form(term(), term(), term()) :: term() def persisted_form(%Post{} = post, metadata, active_language) do persisted_form(post, metadata, active_language, PostMetadata.translations(post.id)) end + @spec persisted_form(term(), term(), term(), term()) :: term() def persisted_form(post, metadata, active_language, translations) do canonical_language = PostMetadata.canonical_language(post, metadata) translation = Map.get(translations, active_language) @@ -64,8 +75,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do } else %{ - "title" => translation && translation.title || "", - "excerpt" => translation && translation.excerpt || "", + "title" => (translation && translation.title) || "", + "excerpt" => (translation && translation.excerpt) || "", "content" => if(translation, do: Posts.editor_body(translation), else: ""), "tags" => Enum.join(post.tags || [], ", "), "categories" => Enum.join(post.categories || [], ", "), @@ -77,22 +88,43 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do end end + @spec maybe_update_draft(term(), term(), term(), term(), term(), term(), term()) :: term() def maybe_update_draft(socket, post_id, post, current_language, next_language, draft, true) do workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id) socket |> assign(:workbench, workbench) - |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)) - |> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)) - |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) - |> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: draft["title"], subtitle: Atom.to_string(post.status || :draft)})) + |> assign( + :post_editor_drafts, + put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft) + ) + |> assign( + :post_editor_active_languages, + Map.put(socket.assigns.post_editor_active_languages, post_id, next_language) + ) + |> assign( + :post_editor_save_states, + Map.put(socket.assigns.post_editor_save_states, post_id, :dirty) + ) + |> assign( + :tab_meta, + Map.put(socket.assigns.tab_meta, {:post, post_id}, %{ + title: draft["title"], + subtitle: Atom.to_string(post.status || :draft) + }) + ) |> maybe_drop_old_language_draft(post_id, current_language, next_language) end def maybe_update_draft(socket, post_id, _post, _current_language, next_language, _draft, false) do - assign(socket, :post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)) + assign( + socket, + :post_editor_active_languages, + Map.put(socket.assigns.post_editor_active_languages, post_id, next_language) + ) end + @spec put_draft_field(term(), term(), term(), term(), term(), term()) :: term() def put_draft_field(socket, post_id, post, active_language, field, value) do metadata = PostMetadata.project_metadata(post.project_id) draft = Map.put(current_draft(socket.assigns, post, metadata, active_language), field, value) @@ -100,15 +132,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do socket |> assign(:workbench, workbench) - |> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, draft)) - |> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) + |> assign( + :post_editor_drafts, + put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, draft) + ) + |> assign( + :post_editor_save_states, + Map.put(socket.assigns.post_editor_save_states, post_id, :dirty) + ) end + @spec put_query_state(term(), term(), term(), term()) :: term() def put_query_state(socket, post_id, kind, value) do key = query_key(kind) - assign(socket, key, Map.put(Map.get(socket.assigns, key, %{}), post_id, to_string(value || ""))) + + assign( + socket, + key, + Map.put(Map.get(socket.assigns, key, %{}), post_id, to_string(value || "")) + ) end + @spec query_value(term(), term(), term()) :: term() def query_value(assigns, kind, post_id) do assigns |> Map.get(query_key(kind), %{}) @@ -118,25 +163,33 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do defp query_key(:tags), do: :post_editor_tag_queries defp query_key(:categories), do: :post_editor_category_queries - defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) when current_language == next_language, - do: socket + defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) + when current_language == next_language, + do: socket defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do - assign(socket, :post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language)) + assign( + socket, + :post_editor_drafts, + delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language) + ) end + @spec toggled_sections(term(), term(), term()) :: term() def toggled_sections(expanded_by_post, post_id, section) do expanded_by_post |> Map.get(post_id, %{metadata: false, excerpt: false}) |> Map.put_new(:metadata, false) |> Map.put_new(:excerpt, false) - |> Map.update!(section, ¬ &1) + |> Map.update!(section, &(not &1)) end + @spec put_nested_map(term(), term(), term(), term()) :: term() def put_nested_map(map, key, nested_key, value) do Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value)) end + @spec delete_nested_map(term(), term(), term()) :: term() def delete_nested_map(map, key, nested_key) do case Map.get(map, key) do nil -> @@ -150,20 +203,26 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do end end - def reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench) + @spec reload_with_assigned_workbench(term(), term()) :: term() + def reload_with_assigned_workbench(socket, reload), + do: reload.(socket, socket.assigns.workbench) + @spec save_state_for_action(term()) :: term() def save_state_for_action(:publish), do: :published def save_state_for_action(_action), do: :saved + @spec record_title(term(), term()) :: term() def record_title(%Translation{title: title}, post), do: blank_to_nil(title) || post.title || post.slug || post.id def record_title(%Post{title: title, slug: slug, id: id}, _post), do: blank_to_nil(title) || blank_to_nil(slug) || id + @spec record_status(term()) :: term() def record_status(%Translation{status: status}), do: status || :draft def record_status(%Post{status: status}), do: status || :draft + @spec editing_canonical_language?(term(), term(), term()) :: term() def editing_canonical_language?(translations, active_language, canonical_language) do active_language == canonical_language or not Map.has_key?(translations, active_language) end diff --git a/lib/bds/desktop/shell_live/post_editor/list_values.ex b/lib/bds/desktop/shell_live/post_editor/list_values.ex index 0be75ad..530fa1a 100644 --- a/lib/bds/desktop/shell_live/post_editor/list_values.ex +++ b/lib/bds/desktop/shell_live/post_editor/list_values.ex @@ -3,17 +3,22 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do alias BDS.{Metadata, Tags} + @spec field_key(term()) :: term() def field_key(:tags), do: "tags" def field_key(:categories), do: "categories" + @spec tag_values(term()) :: term() def tag_values(form), do: csv_to_list(Map.get(form, "tags", "")) + @spec category_values(term()) :: term() def category_values(form), do: csv_to_list(Map.get(form, "categories", "")) + @spec tag_suggestions(term(), term(), term()) :: term() def tag_suggestions(form, options, query) do selected = MapSet.new(tag_values(form)) filter_suggestions(options, query, fn option -> option.name end, selected) end + @spec tag_chips(term(), term()) :: term() def tag_chips(form, options) do option_map = Map.new(options, fn option -> {option.name, option} end) @@ -23,6 +28,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do end) end + @spec category_suggestions(term(), term(), term()) :: term() def category_suggestions(form, options, query) do selected = MapSet.new(category_values(form)) filter_suggestions(options, query, & &1, selected) @@ -34,11 +40,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do options |> Enum.filter(fn option -> label = labeler.(option) - not MapSet.member?(selected, label) and (query == "" or String.contains?(String.downcase(label), query)) + + not MapSet.member?(selected, label) and + (query == "" or String.contains?(String.downcase(label), query)) end) |> Enum.take(8) end + @spec query_addable?(term(), term(), term(), term()) :: term() def query_addable?(query, selected_values, options, labeler) do normalized = normalize_query(query) @@ -54,6 +63,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do |> String.downcase() end + @spec normalize_list_entry(term()) :: term() def normalize_list_entry(value) do value |> to_string() @@ -61,6 +71,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do |> String.downcase() end + @spec ensure_list_value(term(), term(), term()) :: term() def ensure_list_value(project_id, :tags, value) do if Enum.any?(Tags.list_tags(project_id), &(String.downcase(&1.name) == value)) do :ok @@ -83,6 +94,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do _error -> :ok end + @spec csv_to_list(term()) :: term() def csv_to_list(value) do value |> to_string() @@ -91,6 +103,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do |> Enum.reject(&(&1 == "")) end + @spec tag_chip_style(term()) :: term() def tag_chip_style(nil), do: nil def tag_chip_style(color) do @@ -121,5 +134,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do defp contrast_color(_color), do: "#ffffff" + @spec ai_overlay_fields(term()) :: term() def ai_overlay_fields(selected), do: Enum.filter(selected, & &1.accepted) end diff --git a/lib/bds/desktop/shell_live/post_editor/persistence.ex b/lib/bds/desktop/shell_live/post_editor/persistence.ex index 9fd77f3..9bce9b7 100644 --- a/lib/bds/desktop/shell_live/post_editor/persistence.ex +++ b/lib/bds/desktop/shell_live/post_editor/persistence.ex @@ -6,11 +6,16 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do alias BDS.Desktop.ShellData alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, PostMetadata} + @spec persist(term(), term(), term(), term(), term()) :: term() def persist(%Post{} = post, draft, active_language, metadata, action) do canonical_language = PostMetadata.canonical_language(post, metadata) translations = PostMetadata.translations(post.id) - if DraftManagement.editing_canonical_language?(translations, active_language, canonical_language) do + if DraftManagement.editing_canonical_language?( + translations, + active_language, + canonical_language + ) do post |> save_canonical_draft(draft) |> maybe_publish_post(post.id, action) @@ -21,12 +26,17 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do end end + @spec discard(term(), term(), term()) :: term() def discard(%Post{} = post, active_language, metadata) do canonical_language = PostMetadata.canonical_language(post, metadata) current_translations = PostMetadata.translations(post.id) cond do - not DraftManagement.editing_canonical_language?(current_translations, active_language, canonical_language) -> + not DraftManagement.editing_canonical_language?( + current_translations, + active_language, + canonical_language + ) -> {:ok, post} post.file_path not in [nil, ""] and post.status == :draft -> @@ -37,15 +47,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do end end + @spec has_published_version?(term()) :: term() def has_published_version?(%Post{} = post), do: not is_nil(post.published_at) or post.file_path not in [nil, ""] + @spec discard_label(term()) :: term() def discard_label(%Post{} = post) do if has_published_version?(post), do: translated("Discard Changes"), else: translated("Discard Draft") end + @spec discard_title(term()) :: term() def discard_title(%Post{} = post) do if has_published_version?(post), do: translated("Discard changes and restore the published version"), diff --git a/lib/bds/desktop/shell_live/post_editor/post_metadata.ex b/lib/bds/desktop/shell_live/post_editor/post_metadata.ex index 18d25da..67d1598 100644 --- a/lib/bds/desktop/shell_live/post_editor/post_metadata.ex +++ b/lib/bds/desktop/shell_live/post_editor/post_metadata.ex @@ -8,6 +8,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do alias BDS.Media.Media, as: MediaRecord alias BDS.Posts.{Post, PostMedia} + @spec project_metadata(term()) :: term() def project_metadata(nil), do: %{main_language: "en", blog_languages: []} def project_metadata(project_id) do @@ -17,6 +18,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do _error -> %{main_language: "en", blog_languages: []} end + @spec canonical_language(term(), term()) :: term() def canonical_language(post, metadata) do BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language( post.language, @@ -24,28 +26,36 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do ) end + @spec translations(term()) :: term() def translations(post_id) do {:ok, translations} = Posts.list_post_translations(post_id) Map.new(translations, fn translation -> {translation.language, translation} end) end + @spec languages(term()) :: term() def languages(metadata) do - (([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) ++ Enum.map(I18n.supported_languages(), & &1.code)) + (([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) ++ + Enum.map(I18n.supported_languages(), & &1.code)) |> Enum.reject(&is_nil/1) |> Enum.uniq() end + @spec template_options(term()) :: term() def template_options(project_id) do Repo.all( from template in Templates.Template, where: template.project_id == ^project_id, order_by: [asc: template.title, asc: template.slug], - select: %{slug: template.slug, title: fragment("COALESCE(?, ?)", template.title, template.slug)} + select: %{ + slug: template.slug, + title: fragment("COALESCE(?, ?)", template.title, template.slug) + } ) rescue _error -> [] end + @spec linked_media(term()) :: term() def linked_media(post_id) do rows = Repo.all( @@ -74,6 +84,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do _error -> [] end + @spec post_links(term()) :: term() def post_links(post_id) do %{ backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id), @@ -84,15 +95,29 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do defp related_posts(links, key) do Enum.map(links, fn link -> case Posts.get_post(Map.fetch!(link, key)) do - %Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id} - _other -> nil + %Post{} = post -> + %{ + id: post.id, + title: post.title || post.slug || post.id, + text: link.link_text || post.slug || post.id + } + + _other -> + nil end end) |> Enum.reject(&is_nil/1) end + @spec translation_flags(term(), term(), term(), term()) :: term() def translation_flags(post, canonical_language, active_language, translations) do - canonical = %{language: canonical_language, flag: I18n.flag(canonical_language), status: Atom.to_string(post.status || :draft), active: active_language == canonical_language, label: canonical_language} + canonical = %{ + language: canonical_language, + flag: I18n.flag(canonical_language), + status: Atom.to_string(post.status || :draft), + active: active_language == canonical_language, + label: canonical_language + } others = translations @@ -111,6 +136,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do [canonical | others] end + @spec footer(term(), term(), term(), term()) :: term() def footer(post, translation, active_language, canonical_language) do if active_language == canonical_language do %{ @@ -120,8 +146,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do } else %{ - created_at: format_timestamp(translation && translation.created_at || post.created_at), - updated_at: format_timestamp(translation && translation.updated_at || post.updated_at), + created_at: format_timestamp((translation && translation.created_at) || post.created_at), + updated_at: format_timestamp((translation && translation.updated_at) || post.updated_at), published_at: format_timestamp(translation && translation.published_at) } end @@ -135,10 +161,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do |> Calendar.strftime("%x") end + @spec display_title(term(), term(), term()) :: term() def display_title(title, slug, fallback_id) do blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled") end + @spec gallery_count(term()) :: term() def gallery_count(form) do form |> Map.get("content", "") @@ -147,8 +175,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do |> length() end - def preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview, do: nil + @spec preview_url(term(), term(), term(), term()) :: term() + def preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview, + do: nil + @spec preview_url(term(), term(), term(), term()) :: term() def preview_url(%Post{} = post, active_language, canonical_language, :preview) do query = %{} @@ -156,7 +187,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do |> maybe_put_query("post_id", post.id) |> maybe_put_query("lang", active_language != canonical_language && active_language) - Preview.base_url() <> canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query) + Preview.base_url() <> + canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query) end defp canonical_preview_path(created_at_ms, slug) do @@ -171,10 +203,13 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do defp maybe_put_query(query, key, value), do: Map.put(query, key, value) def truthy?(value) when value in [true, "true", "on", 1, "1"], do: true + @spec truthy?(term()) :: term() def truthy?(_value), do: false + @spec blank?(term()) :: term() def blank?(value), do: blank_to_nil(value) == nil + @spec blank_to_nil(term()) :: term() def blank_to_nil(value) do value |> to_string() diff --git a/lib/bds/desktop/shell_live/session_util.ex b/lib/bds/desktop/shell_live/session_util.ex index f48bceb..48eccd4 100644 --- a/lib/bds/desktop/shell_live/session_util.ex +++ b/lib/bds/desktop/shell_live/session_util.ex @@ -19,7 +19,9 @@ defmodule BDS.Desktop.ShellLive.SessionUtil do Stream.iterate(1, &(&1 + 1)) |> Enum.find_value(fn index -> candidate = - if index == 1, do: @default_new_project_name, else: "#{@default_new_project_name} #{index}" + if index == 1, + do: @default_new_project_name, + else: "#{@default_new_project_name} #{index}" if MapSet.member?(existing_names, candidate), do: nil, else: candidate end) diff --git a/lib/bds/desktop/shell_live/settings_editor.ex b/lib/bds/desktop/shell_live/settings_editor.ex index 6eca981..a586d65 100644 --- a/lib/bds/desktop/shell_live/settings_editor.ex +++ b/lib/bds/desktop/shell_live/settings_editor.ex @@ -17,7 +17,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do alias BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings alias BDS.Desktop.ShellLive.SettingsEditor.StyleEditor - embed_templates "settings_editor_html/*" + embed_templates("settings_editor_html/*") @settings_sections ~w(project editor content ai technology publishing data mcp) @supported_languages ["en", "de", "fr", "it", "es"] @@ -45,6 +45,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do defdelegate theme_display_name(theme), to: StyleEditor defdelegate protected_category?(category), to: ManagedCategories + @spec assign_socket(term()) :: term() def assign_socket(socket) do case socket.assigns[:current_tab] do %{type: :settings} -> @@ -64,12 +65,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do end end + @spec update_search(term(), term(), term()) :: term() def update_search(socket, query, reload) do socket |> assign(:settings_editor_search, to_string(query || "")) |> reload.(socket.assigns.workbench) end + @spec build_settings(term()) :: term() def build_settings(%{projects: %{active_project_id: nil}}), do: nil def build_settings(assigns) do @@ -82,7 +85,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do ) editor_form = - Map.merge(EditorSettings.editor_form(), Map.get(assigns, :settings_editor_editor_draft, %{})) + Map.merge( + EditorSettings.editor_form(), + Map.get(assigns, :settings_editor_editor_draft, %{}) + ) ai_form = Map.merge(AISettings.ai_form(assigns), Map.get(assigns, :settings_editor_ai_draft, %{})) @@ -142,6 +148,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do } end + @spec translated(term(), term()) :: term() def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) @@ -171,7 +178,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do Enum.filter(@settings_sections, fn section -> case section do "project" -> - section_matches?(query, ~w(project name description data url language author bookmarklet)) + section_matches?( + query, + ~w(project name description data url language author bookmarklet) + ) "editor" -> section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged)) @@ -195,7 +205,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do section_matches?(query, ~w(data rebuild maintenance links thumbnails filesystem)) "mcp" -> - section_matches?(query, ~w(mcp claude copilot gemini opencode mistral codex agent server)) + section_matches?( + query, + ~w(mcp claude copilot gemini opencode mistral codex agent server) + ) end end) end diff --git a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex index 14469e1..14ff6be 100644 --- a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex +++ b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex @@ -7,6 +7,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do alias BDS.Desktop.ShellData alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings + @spec ai_form(term()) :: term() def ai_form(assigns) do {:ok, online_endpoint} = AI.get_endpoint(:online) {:ok, airplane_endpoint} = AI.get_endpoint(:airplane) @@ -30,18 +31,21 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do } end + @spec endpoint_model_options(term(), term()) :: term() def endpoint_model_options(assigns, endpoint_key) do assigns |> Map.get(:settings_editor_endpoint_models, %{}) |> Map.get(endpoint_key, []) end + @spec update_ai_draft(term(), term(), term()) :: term() def update_ai_draft(socket, params, reload) do socket |> assign(:settings_editor_ai_draft, normalize_ai_params(params)) |> reload.(socket.assigns.workbench) end + @spec refresh_ai_models(term(), term(), term(), term()) :: term() def refresh_ai_models(socket, endpoint_key, reload, append_output) do attrs = ai_attrs(socket.assigns) @@ -65,11 +69,17 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do end end + @spec save_ai(term(), term(), term()) :: term() def save_ai(socket, reload, append_output) do attrs = ai_attrs(socket.assigns) with :ok <- - put_endpoint_preferences(:online, attrs.online_url, attrs.online_api_key, attrs.online_chat_model), + put_endpoint_preferences( + :online, + attrs.online_url, + attrs.online_api_key, + attrs.online_chat_model + ), :ok <- put_endpoint_preferences( :airplane, @@ -85,7 +95,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do :ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model), :ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model), :ok <- - maybe_put_model_preference(:airplane_image_analysis, attrs.offline_image_analysis_model), + maybe_put_model_preference( + :airplane_image_analysis, + attrs.offline_image_analysis_model + ), :ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do socket |> assign(:settings_editor_ai_draft, %{}) @@ -99,6 +112,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do end end + @spec reset_ai_prompt(term(), term(), term()) :: term() def reset_ai_prompt(socket, reload, append_output) do case EditorSettings.put_global_setting("ai.system_prompt", "") do :ok -> diff --git a/lib/bds/desktop/shell_live/settings_editor/editor_settings.ex b/lib/bds/desktop/shell_live/settings_editor/editor_settings.ex index 2daf476..c75fd0c 100644 --- a/lib/bds/desktop/shell_live/settings_editor/editor_settings.ex +++ b/lib/bds/desktop/shell_live/settings_editor/editor_settings.ex @@ -6,21 +6,25 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.EditorSettings do alias BDS.Settings alias BDS.Desktop.ShellData + @spec editor_form() :: term() def editor_form do %{ "default_mode" => get_global_setting("ui.preferred_editor_mode") || "markdown", "diff_view_style" => get_global_setting("ui.git_diff_view_style") || "inline", "wrap_long_lines" => get_global_setting("ui.git_diff_word_wrap") == "true", - "hide_unchanged_regions" => get_global_setting("ui.git_diff_hide_unchanged_regions") == "true" + "hide_unchanged_regions" => + get_global_setting("ui.git_diff_hide_unchanged_regions") == "true" } end + @spec update_editor_draft(term(), term(), term()) :: term() def update_editor_draft(socket, params, reload) do socket |> assign(:settings_editor_editor_draft, normalize_editor_params(params)) |> reload.(socket.assigns.workbench) end + @spec save_editor(term(), term(), term()) :: term() def save_editor(socket, reload, append_output) do attrs = editor_attrs(socket.assigns) @@ -43,10 +47,12 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.EditorSettings do end end + @spec get_global_setting(term()) :: term() def get_global_setting(key) do Settings.get_global_setting(key) end + @spec put_global_setting(term(), term()) :: term() def put_global_setting(key, value) do Settings.put_global_setting(key, value) end diff --git a/lib/bds/desktop/shell_live/settings_editor/managed_categories.ex b/lib/bds/desktop/shell_live/settings_editor/managed_categories.ex index 042647e..30edfdb 100644 --- a/lib/bds/desktop/shell_live/settings_editor/managed_categories.ex +++ b/lib/bds/desktop/shell_live/settings_editor/managed_categories.ex @@ -14,10 +14,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do "page" => %{title: "page", render_in_lists: false, show_title: true} } + @spec protected_categories() :: term() def protected_categories, do: @protected_categories + @spec protected_category?(term()) :: term() def protected_category?(category), do: MapSet.member?(@protected_categories, category) + @spec category_rows(term()) :: term() def category_rows(metadata) do categories = Map.get(metadata, :categories, []) settings = Map.get(metadata, :category_settings, %{}) @@ -37,12 +40,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do end) end + @spec update_new_category(term(), term(), term()) :: term() def update_new_category(socket, name, reload) do socket |> assign(:settings_editor_new_category, to_string(name || "")) |> reload.(socket.assigns.workbench) end + @spec add_category(term(), term(), term()) :: term() def add_category(socket, reload, append_output) do project_id = socket.assigns.projects.active_project_id name = socket.assigns[:settings_editor_new_category] |> to_string() |> String.trim() @@ -73,11 +78,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do end end + @spec reset_categories(term(), term(), term()) :: term() def reset_categories(socket, reload, append_output) do project_id = socket.assigns.projects.active_project_id result = - Enum.reduce_while(category_names(project_metadata(socket.assigns)), :ok, fn category, _acc -> + Enum.reduce_while(category_names(project_metadata(socket.assigns)), :ok, fn category, + _acc -> if MapSet.member?(@protected_categories, category) do {:cont, :ok} else @@ -102,6 +109,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do end end + @spec save_category(term(), term(), term(), term()) :: term() def save_category(socket, params, reload, append_output) do project_id = socket.assigns.projects.active_project_id category = Map.get(params, "category", "") @@ -125,6 +133,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do end end + @spec remove_category(term(), term(), term(), term()) :: term() def remove_category(socket, category, reload, append_output) do project_id = socket.assigns.projects.active_project_id diff --git a/lib/bds/desktop/shell_live/settings_editor/mcp_config.ex b/lib/bds/desktop/shell_live/settings_editor/mcp_config.ex index 29fb5d5..2068d9f 100644 --- a/lib/bds/desktop/shell_live/settings_editor/mcp_config.ex +++ b/lib/bds/desktop/shell_live/settings_editor/mcp_config.ex @@ -16,6 +16,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do %{id: :openai_codex, label: "OpenAI Codex", supported?: false} ] + @spec mcp_rows() :: term() def mcp_rows do Enum.map(@mcp_agents, fn agent -> %{ @@ -28,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do end) end + @spec toggle_mcp_agent(term(), term(), term(), term()) :: term() def toggle_mcp_agent(socket, agent, reload, append_output) do case find_mcp_agent(agent) do %{id: agent_id, supported?: true} = config -> diff --git a/lib/bds/desktop/shell_live/settings_editor/project_settings.ex b/lib/bds/desktop/shell_live/settings_editor/project_settings.ex index 3a00325..31a4581 100644 --- a/lib/bds/desktop/shell_live/settings_editor/project_settings.ex +++ b/lib/bds/desktop/shell_live/settings_editor/project_settings.ex @@ -6,12 +6,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do alias BDS.Metadata alias BDS.Desktop.ShellData + @spec project_metadata(term()) :: term() def project_metadata(assigns) do case Metadata.get_project_metadata(assigns.projects.active_project_id) do {:ok, metadata} -> metadata end end + @spec project_form(term()) :: term() def project_form(metadata) do %{ "name" => Map.get(metadata, :name, ""), @@ -28,18 +30,21 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do } end + @spec technology_form(term()) :: term() def technology_form(project_form) do %{ "semantic_similarity_enabled" => Map.get(project_form, "semantic_similarity_enabled", false) } end + @spec update_project_draft(term(), term(), term()) :: term() def update_project_draft(socket, params, reload) do socket |> assign(:settings_editor_project_draft, normalize_project_params(params)) |> reload.(socket.assigns.workbench) end + @spec save_project(term(), term(), term()) :: term() def save_project(socket, reload, append_output) do project_id = socket.assigns.projects.active_project_id diff --git a/lib/bds/desktop/shell_live/settings_editor/publishing_settings.ex b/lib/bds/desktop/shell_live/settings_editor/publishing_settings.ex index 9ab7bae..fe07902 100644 --- a/lib/bds/desktop/shell_live/settings_editor/publishing_settings.ex +++ b/lib/bds/desktop/shell_live/settings_editor/publishing_settings.ex @@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do alias BDS.Metadata alias BDS.Desktop.ShellData + @spec publishing_form(term()) :: term() def publishing_form(metadata) do prefs = Map.get(metadata, :publishing_preferences, %{}) @@ -17,12 +18,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do } end + @spec update_publishing_draft(term(), term(), term()) :: term() def update_publishing_draft(socket, params, reload) do socket |> assign(:settings_editor_publishing_draft, normalize_publishing_params(params)) |> reload.(socket.assigns.workbench) end + @spec save_publishing(term(), term(), term()) :: term() def save_publishing(socket, reload, append_output) do project_id = socket.assigns.projects.active_project_id @@ -39,6 +42,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do end end + @spec clear_publishing(term(), term(), term()) :: term() def clear_publishing(socket, reload, append_output) do project_id = socket.assigns.projects.active_project_id diff --git a/lib/bds/desktop/shell_live/settings_editor/style_editor.ex b/lib/bds/desktop/shell_live/settings_editor/style_editor.ex index 6835f81..d507c56 100644 --- a/lib/bds/desktop/shell_live/settings_editor/style_editor.ex +++ b/lib/bds/desktop/shell_live/settings_editor/style_editor.ex @@ -29,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do "zinc" ] + @spec build_style(term()) :: term() def build_style(%{projects: %{active_project_id: nil}}), do: nil def build_style(assigns) do @@ -40,22 +41,26 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do selected_theme: selected_theme, applied_theme: current_theme(assigns), preview_mode: preview_mode, - preview_url: "http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}" + preview_url: + "http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}" } end + @spec select_style_theme(term(), term(), term()) :: term() def select_style_theme(socket, theme, reload) do socket |> assign(:style_editor_theme, to_string(theme || "default")) |> reload.(socket.assigns.workbench) end + @spec change_style_preview_mode(term(), term(), term()) :: term() def change_style_preview_mode(socket, mode, reload) do socket |> assign(:style_editor_preview_mode, to_string(mode || "auto")) |> reload.(socket.assigns.workbench) end + @spec apply_style_theme(term(), term(), term()) :: term() def apply_style_theme(socket, reload, append_output) do project_id = socket.assigns.projects.active_project_id theme = socket.assigns[:style_editor_theme] || current_theme(socket.assigns) @@ -71,6 +76,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do end end + @spec theme_display_name(term()) :: term() def theme_display_name(theme) do theme |> to_string() @@ -78,6 +84,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do |> String.capitalize() end + @spec current_theme(term()) :: term() def current_theme(assigns) do case Metadata.get_project_metadata(assigns.projects.active_project_id) do {:ok, metadata} -> diff --git a/lib/bds/desktop/shell_live/sidebar_create.ex b/lib/bds/desktop/shell_live/sidebar_create.ex index 21177ee..b0c6676 100644 --- a/lib/bds/desktop/shell_live/sidebar_create.ex +++ b/lib/bds/desktop/shell_live/sidebar_create.ex @@ -22,7 +22,13 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do end def create(socket, project_id, "post", callbacks) do - case BDS.Posts.create_post(%{project_id: project_id, title: "", content: "", tags: [], categories: []}) do + case BDS.Posts.create_post(%{ + project_id: project_id, + title: "", + content: "", + tags: [], + categories: [] + }) do {:ok, _post} -> callbacks.reload.(socket, socket.assigns.workbench) @@ -42,7 +48,12 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do {:error, reason} -> socket - |> callbacks.append_output.(translated("sidebar.importMedia"), inspect(reason), nil, "error") + |> callbacks.append_output.( + translated("sidebar.importMedia"), + inspect(reason), + nil, + "error" + ) |> callbacks.reload.(socket.assigns.workbench) end @@ -68,13 +79,23 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do {:ok, script} -> callbacks.open_sidebar.( socket, - %{"route" => "scripts", "id" => script.id, "title" => script.title, "subtitle" => "Automation helpers"}, + %{ + "route" => "scripts", + "id" => script.id, + "title" => script.title, + "subtitle" => "Automation helpers" + }, :pin ) {:error, reason} -> socket - |> callbacks.append_output.(translated("sidebar.scripts.newScript"), inspect(reason), nil, "error") + |> callbacks.append_output.( + translated("sidebar.scripts.newScript"), + inspect(reason), + nil, + "error" + ) |> callbacks.reload.(socket.assigns.workbench) end end @@ -90,29 +111,52 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do {:ok, template} -> callbacks.open_sidebar.( socket, - %{"route" => "templates", "id" => template.id, "title" => template.title, "subtitle" => "Site rendering"}, + %{ + "route" => "templates", + "id" => template.id, + "title" => template.title, + "subtitle" => "Site rendering" + }, :pin ) {:error, reason} -> socket - |> callbacks.append_output.(translated("sidebar.templates.newTemplate"), inspect(reason), nil, "error") + |> callbacks.append_output.( + translated("sidebar.templates.newTemplate"), + inspect(reason), + nil, + "error" + ) |> callbacks.reload.(socket.assigns.workbench) end end def create(socket, project_id, "import", callbacks) do - case ImportDefinitions.create_definition(%{project_id: project_id, name: translated("sidebar.import.newDefinition")}) do + case ImportDefinitions.create_definition(%{ + project_id: project_id, + name: translated("sidebar.import.newDefinition") + }) do {:ok, definition} -> callbacks.open_sidebar.( socket, - %{"route" => "import", "id" => definition.id, "title" => definition.name, "subtitle" => "Import definitions"}, + %{ + "route" => "import", + "id" => definition.id, + "title" => definition.name, + "subtitle" => "Import definitions" + }, :pin ) {:error, reason} -> socket - |> callbacks.append_output.(translated("sidebar.import.newDefinition"), inspect(reason), nil, "error") + |> callbacks.append_output.( + translated("sidebar.import.newDefinition"), + inspect(reason), + nil, + "error" + ) |> callbacks.reload.(socket.assigns.workbench) end end diff --git a/lib/bds/desktop/shell_live/sidebar_state.ex b/lib/bds/desktop/shell_live/sidebar_state.ex index e4cf157..cf71dc3 100644 --- a/lib/bds/desktop/shell_live/sidebar_state.ex +++ b/lib/bds/desktop/shell_live/sidebar_state.ex @@ -7,13 +7,17 @@ defmodule BDS.Desktop.ShellLive.SidebarState do if is_map(filters) and Map.get(filters, :enabled) do panel_state = filter_panel_state(socket, view_id) - Map.put(sidebar_data, :filters, Map.merge(filters, %{ - filter_panel_visible: panel_state.visible, - archive_collapsed: panel_state.archive_collapsed, - tags_collapsed: panel_state.tags_collapsed, - categories_collapsed: panel_state.categories_collapsed, - expanded_year: panel_state.expanded_year - })) + Map.put( + sidebar_data, + :filters, + Map.merge(filters, %{ + filter_panel_visible: panel_state.visible, + archive_collapsed: panel_state.archive_collapsed, + tags_collapsed: panel_state.tags_collapsed, + categories_collapsed: panel_state.categories_collapsed, + expanded_year: panel_state.expanded_year + }) + ) else sidebar_data end @@ -22,7 +26,12 @@ defmodule BDS.Desktop.ShellLive.SidebarState do def put_filter_panel_state(socket, updater) do view_id = Atom.to_string(socket.assigns.workbench.active_view) state = socket |> filter_panel_state(view_id) |> updater.() - Phoenix.Component.assign(socket, :sidebar_filter_panels, Map.put(socket.assigns.sidebar_filter_panels, view_id, state)) + + Phoenix.Component.assign( + socket, + :sidebar_filter_panels, + Map.put(socket.assigns.sidebar_filter_panels, view_id, state) + ) end def current_filters(socket, view_id) do @@ -33,8 +42,17 @@ defmodule BDS.Desktop.ShellLive.SidebarState do def put_filters(socket, updater) do view_id = Atom.to_string(socket.assigns.workbench.active_view) - filters = current_filters(socket, view_id) |> updater.() |> normalize_filters(socket.assigns.sidebar_data) - Phoenix.Component.assign(socket, :sidebar_filters_by_view, Map.put(socket.assigns.sidebar_filters_by_view, view_id, filters)) + + filters = + current_filters(socket, view_id) + |> updater.() + |> normalize_filters(socket.assigns.sidebar_data) + + Phoenix.Component.assign( + socket, + :sidebar_filters_by_view, + Map.put(socket.assigns.sidebar_filters_by_view, view_id, filters) + ) end def toggle_filter_value(filters, key, value) do diff --git a/lib/bds/desktop/shell_live/tags_editor.ex b/lib/bds/desktop/shell_live/tags_editor.ex index 4d3fe23..b18738d 100644 --- a/lib/bds/desktop/shell_live/tags_editor.ex +++ b/lib/bds/desktop/shell_live/tags_editor.ex @@ -11,12 +11,14 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do alias BDS.Tags.Tag alias BDS.Templates.Template - embed_templates "tags_editor_html/*" + embed_templates("tags_editor_html/*") + @spec assign_socket(term()) :: term() def assign_socket(socket) do assign(socket, :tags_editor, build(socket.assigns)) end + @spec toggle_selection(term(), term(), term()) :: term() def toggle_selection(socket, tag_name, reload) do selected = Map.get(socket.assigns, :tags_editor_selected, []) @@ -33,6 +35,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do |> reload.(socket.assigns.workbench) end + @spec update_new_tag(term(), term(), term()) :: term() def update_new_tag(socket, params, reload) do socket |> assign(:tags_editor_new_tag, %{ @@ -42,11 +45,16 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do |> reload.(socket.assigns.workbench) end + @spec create_tag(term(), term(), term()) :: term() def create_tag(socket, reload, append_output) do project_id = socket.assigns.projects.active_project_id draft = Map.get(socket.assigns, :tags_editor_new_tag, %{}) - case Tags.create_tag(%{project_id: project_id, name: Map.get(draft, "name"), color: blank_to_nil(Map.get(draft, "color"))}) do + case Tags.create_tag(%{ + project_id: project_id, + name: Map.get(draft, "name"), + color: blank_to_nil(Map.get(draft, "color")) + }) do {:ok, _tag} -> socket |> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""}) @@ -59,6 +67,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do end end + @spec update_edit_tag(term(), term(), term()) :: term() def update_edit_tag(socket, params, reload) do socket |> assign(:tags_editor_edit_draft, %{ @@ -69,16 +78,26 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do |> reload.(socket.assigns.workbench) end + @spec save_tag(term(), term(), term()) :: term() def save_tag(socket, reload, append_output) do selected = Map.get(socket.assigns, :tags_editor_selected, []) draft = Map.get(socket.assigns, :tags_editor_edit_draft, %{}) case selected do [tag_name] -> - case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do - nil -> reload.(socket, socket.assigns.workbench) + case Repo.get_by(Tag, + project_id: socket.assigns.projects.active_project_id, + name: tag_name + ) do + nil -> + reload.(socket, socket.assigns.workbench) + %Tag{} = tag -> - with {:ok, _updated_tag} <- Tags.update_tag(tag.id, %{color: blank_to_nil(Map.get(draft, "color")), post_template_slug: blank_to_nil(Map.get(draft, "post_template_slug"))}), + with {:ok, _updated_tag} <- + Tags.update_tag(tag.id, %{ + color: blank_to_nil(Map.get(draft, "color")), + post_template_slug: blank_to_nil(Map.get(draft, "post_template_slug")) + }), {:ok, renamed_tag} <- maybe_rename_tag(tag, Map.get(draft, "name", tag.name)) do socket |> assign(:tags_editor_selected, [renamed_tag.name]) @@ -92,15 +111,22 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do end end - _other -> reload.(socket, socket.assigns.workbench) + _other -> + reload.(socket, socket.assigns.workbench) end end + @spec delete_selected(term(), term(), term()) :: term() def delete_selected(socket, reload, append_output) do case Map.get(socket.assigns, :tags_editor_selected, []) do [tag_name] -> - case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do - nil -> reload.(socket, socket.assigns.workbench) + case Repo.get_by(Tag, + project_id: socket.assigns.projects.active_project_id, + name: tag_name + ) do + nil -> + reload.(socket, socket.assigns.workbench) + %Tag{} = tag -> case Tags.delete_tag(tag.id) do {:ok, _deleted} -> @@ -116,16 +142,19 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do end end - _other -> reload.(socket, socket.assigns.workbench) + _other -> + reload.(socket, socket.assigns.workbench) end end + @spec update_merge_target(term(), term(), term()) :: term() def update_merge_target(socket, target, reload) do socket |> assign(:tags_editor_merge_target, to_string(target || "")) |> reload.(socket.assigns.workbench) end + @spec merge_selected(term(), term(), term()) :: term() def merge_selected(socket, reload, append_output) do selected = Map.get(socket.assigns, :tags_editor_selected, []) target_name = Map.get(socket.assigns, :tags_editor_merge_target, "") @@ -136,12 +165,19 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do true -> project_id = socket.assigns.projects.active_project_id - tags = Repo.all(from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^selected) + + tags = + Repo.all( + from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^selected + ) + target = Enum.find(tags, &(&1.name == target_name)) sources = Enum.reject(tags, &(&1.name == target_name)) case target do - nil -> reload.(socket, socket.assigns.workbench) + nil -> + reload.(socket, socket.assigns.workbench) + _target -> case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do {:ok, _merged} -> @@ -160,23 +196,41 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do end end + @spec sync(term(), term(), term()) :: term() def sync(socket, reload, append_output) do _ = append_output :ok = Tags.sync_tags_json(socket.assigns.projects.active_project_id) reload.(socket, socket.assigns.workbench) end + @spec build(term()) :: term() def build(%{current_tab: %{type: :tags}} = assigns) do project_id = assigns.projects.active_project_id - tags = Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name]) + + tags = + Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name]) + counts = tag_counts(project_id) selected = Map.get(assigns, :tags_editor_selected, []) - edit_tag = if length(selected) == 1, do: Enum.find(tags, &(&1.name == hd(selected))), else: nil + + edit_tag = + if length(selected) == 1, do: Enum.find(tags, &(&1.name == hd(selected))), else: nil + edit_draft = Map.get(assigns, :tags_editor_edit_draft, edit_draft(edit_tag)) - templates = Repo.all(from template in Template, where: template.project_id == ^project_id, order_by: [asc: template.title], select: %{slug: template.slug, title: template.title}) + + templates = + Repo.all( + from template in Template, + where: template.project_id == ^project_id, + order_by: [asc: template.title], + select: %{slug: template.slug, title: template.title} + ) %{ - tags: Enum.map(tags, fn tag -> %{name: tag.name, color: tag.color, count: Map.get(counts, tag.name, 0)} end), + tags: + Enum.map(tags, fn tag -> + %{name: tag.name, color: tag.color, count: Map.get(counts, tag.name, 0)} + end), selected: selected, new_tag: Map.get(assigns, :tags_editor_new_tag, %{"name" => "", "color" => ""}), edit_draft: edit_draft, @@ -187,14 +241,18 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do def build(_assigns), do: nil - def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + @spec translated(term(), term()) :: term() + def translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + @spec tag_font_size(term(), term()) :: term() def tag_font_size(count, counts) do max_count = Enum.max([1 | Enum.map(counts, & &1.count)]) ratio = if max_count <= 1, do: 0.0, else: (count - 1) / max(max_count - 1, 1) Float.round(0.85 + (1.8 - 0.85) * ratio, 2) end + @spec tag_style(term(), term()) :: term() def tag_style(tag, counts) do size = tag_font_size(tag.count, counts) @@ -217,7 +275,13 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do defp maybe_seed_edit_draft(socket, _selected), do: assign(socket, :tags_editor_edit_draft, %{}) defp edit_draft(nil), do: %{} - defp edit_draft(%Tag{} = tag), do: %{"name" => tag.name, "color" => tag.color || "", "post_template_slug" => tag.post_template_slug || ""} + + defp edit_draft(%Tag{} = tag), + do: %{ + "name" => tag.name, + "color" => tag.color || "", + "post_template_slug" => tag.post_template_slug || "" + } defp maybe_rename_tag(%Tag{} = tag, next_name) do normalized = String.trim(to_string(next_name || tag.name)) @@ -237,6 +301,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do end defp blank_to_nil(nil), do: nil + defp blank_to_nil(value) do case String.trim(to_string(value)) do "" -> nil diff --git a/lib/bds/desktop/shell_live/task_localization.ex b/lib/bds/desktop/shell_live/task_localization.ex index 6599667..2935383 100644 --- a/lib/bds/desktop/shell_live/task_localization.ex +++ b/lib/bds/desktop/shell_live/task_localization.ex @@ -46,7 +46,10 @@ defmodule BDS.Desktop.ShellLive.TaskLocalization do |> Map.put(:message, localize_task_message(Map.get(task, :message), locale)) |> Map.put(:group_name, localize_task_group(Map.get(task, :group_name), locale)) |> Map.put(:status_label, localize_task_status_label(task.status, locale)) - |> Map.put(:progress_label, if(is_number(progress), do: progress_percent(progress), else: nil)) + |> Map.put( + :progress_label, + if(is_number(progress), do: progress_percent(progress), else: nil) + ) end defp localize_task_message(nil, _locale), do: nil diff --git a/lib/bds/desktop/shell_live/titlebar_menu.ex b/lib/bds/desktop/shell_live/titlebar_menu.ex index 8def69e..491f128 100644 --- a/lib/bds/desktop/shell_live/titlebar_menu.ex +++ b/lib/bds/desktop/shell_live/titlebar_menu.ex @@ -41,7 +41,9 @@ defmodule BDS.Desktop.ShellLive.TitlebarMenu do @spec active_group(map()) :: map() | nil def active_group(assigns) do - Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end) + Enum.find(assigns.menu_groups || [], fn group -> + Atom.to_string(group.id) == assigns.titlebar_menu_group + end) end @spec active_items(map()) :: [map()] @@ -90,7 +92,9 @@ defmodule BDS.Desktop.ShellLive.TitlebarMenu do Handle a keydown event on an open titlebar menu. `invoke_fun` is called with the action id (string) when the user activates an item. """ - @spec handle_keydown(Phoenix.LiveView.Socket.t(), String.t(), (Phoenix.LiveView.Socket.t(), String.t() -> Phoenix.LiveView.Socket.t())) :: + @spec handle_keydown(Phoenix.LiveView.Socket.t(), String.t(), (Phoenix.LiveView.Socket.t(), + String.t() -> + Phoenix.LiveView.Socket.t())) :: Phoenix.LiveView.Socket.t() def handle_keydown(socket, key, invoke_fun) do if socket.assigns.titlebar_menu_group do @@ -114,7 +118,9 @@ defmodule BDS.Desktop.ShellLive.TitlebarMenu do defp rotate_group(socket, offset) do groups = socket.assigns.menu_groups || [] current_group = socket.assigns.titlebar_menu_group - current_index = Enum.find_index(groups, fn group -> Atom.to_string(group.id) == current_group end) + + current_index = + Enum.find_index(groups, fn group -> Atom.to_string(group.id) == current_group end) if is_nil(current_index) or groups == [] do socket diff --git a/lib/bds/embeddings/backends/in_app.ex b/lib/bds/embeddings/backends/in_app.ex index e9bf768..d1b20c7 100644 --- a/lib/bds/embeddings/backends/in_app.ex +++ b/lib/bds/embeddings/backends/in_app.ex @@ -57,4 +57,4 @@ defmodule BDS.Embeddings.Backends.InApp do Enum.map(vector, &(&1 / norm)) end end -end \ No newline at end of file +end diff --git a/lib/bds/frontmatter.ex b/lib/bds/frontmatter.ex index 553f26c..96ddd5d 100644 --- a/lib/bds/frontmatter.ex +++ b/lib/bds/frontmatter.ex @@ -117,7 +117,9 @@ defmodule BDS.Frontmatter do defp take_block_scalar_lines([line | rest], lines) do if String.starts_with?(line, @block_scalar_indent) do - take_block_scalar_lines(rest, [String.replace_prefix(line, @block_scalar_indent, "") | lines]) + take_block_scalar_lines(rest, [ + String.replace_prefix(line, @block_scalar_indent, "") | lines + ]) else {Enum.reverse(lines), [line | rest]} end diff --git a/lib/bds/generation.ex b/lib/bds/generation.ex index 57459d9..f267a8f 100644 --- a/lib/bds/generation.ex +++ b/lib/bds/generation.ex @@ -2,13 +2,16 @@ defmodule BDS.Generation do @moduledoc false import Ecto.Query + import BDS.Generation.Paths, except: [post_output_path: 1, post_output_path: 2] + import BDS.Generation.Sitemap, only: [ render: 1, render_multi_language: 6 ] + import BDS.Generation.Progress import BDS.Generation.Outputs import BDS.Generation.Data @@ -89,7 +92,8 @@ defmodule BDS.Generation do {:ok, validation_report()} | {:error, term()} def validate_site(project_id, sections \\ @core_sections, opts \\ []) - def validate_site(project_id, sections, opts) when is_binary(project_id) and is_list(sections) and is_list(opts) do + def validate_site(project_id, sections, opts) + when is_binary(project_id) and is_list(sections) and is_list(opts) do with {:ok, plan} <- plan_generation(project_id, sections) do on_progress = callback(opts) :ok = report_validation_progress(on_progress, 0.0, "Collecting sitemap URLs...") @@ -104,9 +108,12 @@ defmodule BDS.Generation do {:ok, generated_files_list} = list_generated_files(project_id) generated_file_updated_at = generated_file_updated_at_map(generated_files_list) additional_languages = additional_languages(plan) - published_route_posts = suppress_subtree_translation_variants(data.published_route_posts, additional_languages) - {sitemap_content, sitemap_to_write, additional_expected_paths, additional_post_timestamp_checks} = + published_route_posts = + suppress_subtree_translation_variants(data.published_route_posts, additional_languages) + + {sitemap_content, sitemap_to_write, additional_expected_paths, + additional_post_timestamp_checks} = build_validation_sitemap_artifacts( plan, data, @@ -155,8 +162,8 @@ defmodule BDS.Generation do @spec apply_validation(String.t(), [section()] | map()) :: {:ok, map()} | {:error, term()} def apply_validation(project_id, sections) when is_binary(project_id) and is_list(sections) do - with {:ok, plan} <- plan_generation(project_id, sections), - {:ok, actual_files} <- disk_generated_files(project_id) do + with {:ok, plan} <- plan_generation(project_id, sections), + {:ok, actual_files} <- disk_generated_files(project_id) do expected_outputs = build_outputs(plan) expected_paths = MapSet.new(Enum.map(expected_outputs, &elem(&1, 0))) project = Projects.get_project!(project_id) @@ -190,7 +197,8 @@ defmodule BDS.Generation do generated_files_on_disk |> Map.keys() |> Enum.filter(fn relative_path -> - path_section(relative_path) in plan.sections and not MapSet.member?(expected_paths, relative_path) + path_section(relative_path) in plan.sections and + not MapSet.member?(expected_paths, relative_path) end) |> Enum.each(fn relative_path -> _ = File.rm(output_path(project, relative_path)) @@ -215,6 +223,7 @@ defmodule BDS.Generation do expected_output_map = Map.new(expected_outputs) project = Projects.get_project!(project_id) published_posts = list_published_posts(project_id) + targeted_plan = build_targeted_validation_plan( plan_validation_paths(report_paths(report), additional_languages(plan)), @@ -224,7 +233,12 @@ defmodule BDS.Generation do outputs_to_render = expected_outputs |> Enum.filter(fn {relative_path, _content} -> - targeted_output?(relative_path, targeted_plan, plan.language, additional_languages(plan)) + targeted_output?( + relative_path, + targeted_plan, + plan.language, + additional_languages(plan) + ) end) Enum.each(outputs_to_render, fn {relative_path, content} -> @@ -243,7 +257,10 @@ defmodule BDS.Generation do {:ok, %{ - rendered_url_count: Enum.count(outputs_to_render, fn {relative_path, _content} -> route_html_path?(relative_path) end), + rendered_url_count: + Enum.count(outputs_to_render, fn {relative_path, _content} -> + route_html_path?(relative_path) + end), deleted_url_count: deleted_url_count, removed_empty_dir_count: removed_empty_dir_count }} @@ -257,15 +274,21 @@ defmodule BDS.Generation do defdelegate post_output_path(post, language), to: Paths @typedoc "Result returned by `write_generated_file/3,4`." - @type write_result :: %{relative_path: String.t(), content_hash: String.t(), written?: boolean()} + @type write_result :: %{ + relative_path: String.t(), + content_hash: String.t(), + written?: boolean() + } @spec write_generated_file(String.t(), String.t(), String.t()) :: {:ok, write_result()} def write_generated_file(project_id, relative_path, content), do: write_generated_file(project_id, relative_path, content, []) - @spec write_generated_file(String.t(), String.t(), String.t(), keyword()) :: {:ok, write_result()} + @spec write_generated_file(String.t(), String.t(), String.t(), keyword()) :: + {:ok, write_result()} def write_generated_file(project_id, relative_path, content, opts) - when is_binary(project_id) and is_binary(relative_path) and is_binary(content) and is_list(opts) do + when is_binary(project_id) and is_binary(relative_path) and is_binary(content) and + is_list(opts) do project = Projects.get_project!(project_id) content_hash = sha256(content) now = Persistence.now_ms() @@ -331,8 +354,12 @@ defmodule BDS.Generation do data = generation_data(plan) published_translations = flattened_generation_translations(data.translations_by_post) translations_by_post_language = translation_lookup_map(published_translations) - translatable_published_posts = Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) - translatable_published_list_posts = Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) + + translatable_published_posts = + Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) + + translatable_published_list_posts = + Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) localized_posts_by_language = additional_languages(plan) @@ -421,7 +448,10 @@ defmodule BDS.Generation do pagefind_outputs = if :core in plan.sections do - BDS.Generation.Pagefind.build_outputs(plan, core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs) + BDS.Generation.Pagefind.build_outputs( + plan, + core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs + ) else [] end @@ -433,7 +463,9 @@ defmodule BDS.Generation do [] end - core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs ++ sitemap ++ pagefind_outputs ++ asset_outputs + core_outputs ++ + page_outputs ++ + single_outputs ++ archive_outputs ++ sitemap ++ pagefind_outputs ++ asset_outputs end defp build_validation_sitemap_artifacts( @@ -454,17 +486,27 @@ defmodule BDS.Generation do additional_language_sets = Enum.map(additional_languages(plan), fn language -> - language_posts = Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) - language_list_posts = Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) + language_posts = + Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) + + language_list_posts = + Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) + language_post_index = build_generation_post_index(language_list_posts) - {language, - language_posts, - build_validation_route_paths(plan, language_posts, language_list_posts, language_post_index, language)} + {language, language_posts, + build_validation_route_paths( + plan, + language_posts, + language_list_posts, + language_post_index, + language + )} end) all_collection_paths = - main_paths ++ Enum.flat_map(additional_language_sets, fn {_language, _posts, paths} -> paths end) + main_paths ++ + Enum.flat_map(additional_language_sets, fn {_language, _posts, paths} -> paths end) total_route_count = max(length(all_collection_paths), 1) @@ -497,7 +539,8 @@ defmodule BDS.Generation do sitemap_to_write = case additional_languages(plan) do - [] -> sitemap_content + [] -> + sitemap_content languages -> render_multi_language( @@ -510,7 +553,8 @@ defmodule BDS.Generation do ) end - {sitemap_content, sitemap_to_write, additional_expected_paths, additional_post_timestamp_checks} + {sitemap_content, sitemap_to_write, additional_expected_paths, + additional_post_timestamp_checks} end defp disk_generated_files(project_id) do @@ -544,21 +588,52 @@ defmodule BDS.Generation do segments = String.split(relative_path, "/", trim: true) case strip_language_prefix(segments) do - ["404.html"] -> :core - ["index.html"] -> :core - ["page", _page, "index.html"] -> :core - ["sitemap.xml"] -> :core - ["feed.xml"] -> :core - ["atom.xml"] -> :core - ["calendar.json"] -> :core - ["pagefind" | _rest] -> :core - [year, month, day, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 -> :date - [year, month, day, _slug, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 -> :single - ["category" | _rest] -> :category - ["tag" | _rest] -> :tag - [year, "index.html"] when byte_size(year) == 4 -> :date - [year, month, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 -> :date - _other -> :core + ["404.html"] -> + :core + + ["index.html"] -> + :core + + ["page", _page, "index.html"] -> + :core + + ["sitemap.xml"] -> + :core + + ["feed.xml"] -> + :core + + ["atom.xml"] -> + :core + + ["calendar.json"] -> + :core + + ["pagefind" | _rest] -> + :core + + [year, month, day, "index.html"] + when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 -> + :date + + [year, month, day, _slug, "index.html"] + when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 -> + :single + + ["category" | _rest] -> + :category + + ["tag" | _rest] -> + :tag + + [year, "index.html"] when byte_size(year) == 4 -> + :date + + [year, month, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 -> + :date + + _other -> + :core end end @@ -615,7 +690,9 @@ defmodule BDS.Generation do generated_file.relative_path == ^relative_path ) - {pruned_count, _last_dir} = prune_empty_parent_dirs(Path.dirname(full_path), output_path(project, "")) + {pruned_count, _last_dir} = + prune_empty_parent_dirs(Path.dirname(full_path), output_path(project, "")) + {deleted_count + 1, removed_dir_count + pruned_count} {:error, :enoent} -> @@ -634,7 +711,12 @@ defmodule BDS.Generation do end) Enum.each(ancillary_paths, fn relative_path -> - _ = write_generated_file(project_id, relative_path, Map.fetch!(expected_output_map, relative_path)) + _ = + write_generated_file( + project_id, + relative_path, + Map.fetch!(expected_output_map, relative_path) + ) end) :ok diff --git a/lib/bds/generation/data.ex b/lib/bds/generation/data.ex index 7390656..ba5971e 100644 --- a/lib/bds/generation/data.ex +++ b/lib/bds/generation/data.ex @@ -40,7 +40,13 @@ defmodule BDS.Generation.Data do post_snapshot_candidates |> Enum.with_index(1) |> Enum.reduce(%{}, fn {post, index}, acc -> - :ok = report_snapshot_stage_progress(on_snapshot_progress, :posts, index, length(post_snapshot_candidates)) + :ok = + report_snapshot_stage_progress( + on_snapshot_progress, + :posts, + index, + length(post_snapshot_candidates) + ) case published_post_snapshot(project_data_dir, post) do nil -> acc @@ -54,7 +60,9 @@ defmodule BDS.Generation.Data do |> then(fn published -> draft_candidates |> merge_generation_snapshots(snapshots_by_id) - |> Enum.reduce(Map.new(published, &{&1.id, &1}), fn post, acc -> Map.put(acc, post.id, post) end) + |> Enum.reduce(Map.new(published, &{&1.id, &1}), fn post, acc -> + Map.put(acc, post.id, post) + end) |> Map.values() end) |> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)}) @@ -100,7 +108,12 @@ defmodule BDS.Generation.Data do end @spec resolve_posts_for_language([map()], String.t() | nil, map(), String.t() | nil) :: [map()] - def resolve_posts_for_language(posts, target_language, translations_by_post_language, main_language) do + def resolve_posts_for_language( + posts, + target_language, + translations_by_post_language, + main_language + ) do target = String.downcase(to_string(target_language || "")) main = String.downcase(to_string(main_language || "")) @@ -126,22 +139,42 @@ defmodule BDS.Generation.Data do @spec build_generation_post_index([map()]) :: map() def build_generation_post_index(posts) do - Enum.reduce(posts, %{posts_by_category: %{}, posts_by_tag: %{}, posts_by_year: %{}, posts_by_year_month: %{}, posts_by_year_month_day: %{}}, fn post, acc -> - {year, month_value, day_value} = local_date_parts!(post.created_at) - month = String.pad_leading(Integer.to_string(month_value), 2, "0") - day = String.pad_leading(Integer.to_string(day_value), 2, "0") - year_month = "#{year}/#{month}" - year_month_day = "#{year}/#{month}/#{day}" + Enum.reduce( + posts, + %{ + posts_by_category: %{}, + posts_by_tag: %{}, + posts_by_year: %{}, + posts_by_year_month: %{}, + posts_by_year_month_day: %{} + }, + fn post, acc -> + {year, month_value, day_value} = local_date_parts!(post.created_at) + month = String.pad_leading(Integer.to_string(month_value), 2, "0") + day = String.pad_leading(Integer.to_string(day_value), 2, "0") + year_month = "#{year}/#{month}" + year_month_day = "#{year}/#{month}/#{day}" - acc - |> append_generation_index(:posts_by_year, year, post) - |> append_generation_index(:posts_by_year_month, year_month, post) - |> append_generation_index(:posts_by_year_month_day, year_month_day, post) - |> then(fn indexed -> - indexed = Enum.reduce(post.categories || [], indexed, &append_generation_index(&2, :posts_by_category, &1, post)) - Enum.reduce(post.tags || [], indexed, &append_generation_index(&2, :posts_by_tag, &1, post)) - end) - end) + acc + |> append_generation_index(:posts_by_year, year, post) + |> append_generation_index(:posts_by_year_month, year_month, post) + |> append_generation_index(:posts_by_year_month_day, year_month_day, post) + |> then(fn indexed -> + indexed = + Enum.reduce( + post.categories || [], + indexed, + &append_generation_index(&2, :posts_by_category, &1, post) + ) + + Enum.reduce( + post.tags || [], + indexed, + &append_generation_index(&2, :posts_by_tag, &1, post) + ) + end) + end + ) end ## --- internals ----------------------------------------------------------- @@ -168,9 +201,11 @@ defmodule BDS.Generation.Data do "page" => %{render_in_lists: false, show_title: true} } - Enum.reduce(Map.get(plan, :category_settings, %{}) || %{}, defaults, fn {category, settings}, acc -> + Enum.reduce(Map.get(plan, :category_settings, %{}) || %{}, defaults, fn {category, settings}, + acc -> Map.put(acc, category, %{ - render_in_lists: category_setting_flag(settings, :render_in_lists, "render_in_lists", true), + render_in_lists: + category_setting_flag(settings, :render_in_lists, "render_in_lists", true), show_title: category_setting_flag(settings, :show_title, "show_title", true) }) end) @@ -207,23 +242,30 @@ defmodule BDS.Generation.Data do {:ok, contents} -> {:ok, %{fields: fields}} = Frontmatter.parse_document(contents) - %Post{fallback_post | - id: DocumentFields.get(fields, "id", fallback_post.id), - title: DocumentFields.get(fields, "title", fallback_post.title) || "", - slug: DocumentFields.fetch!(fields, "slug"), - excerpt: Map.get(fields, "excerpt"), - content: nil, - status: :published, - author: Map.get(fields, "author"), - language: Map.get(fields, "language", fallback_post.language), - do_not_translate: DocumentFields.get(fields, "doNotTranslate", fallback_post.do_not_translate || false), - template_slug: DocumentFields.get(fields, "templateSlug", fallback_post.template_slug), - created_at: DocumentFields.get(fields, "createdAt", fallback_post.created_at), - updated_at: DocumentFields.get(fields, "updatedAt", fallback_post.updated_at), - published_at: DocumentFields.get(fields, "publishedAt", fallback_post.published_at), - file_path: fallback_post.file_path, - tags: Map.get(fields, "tags", fallback_post.tags || []), - categories: Map.get(fields, "categories", fallback_post.categories || []) + %Post{ + fallback_post + | id: DocumentFields.get(fields, "id", fallback_post.id), + title: DocumentFields.get(fields, "title", fallback_post.title) || "", + slug: DocumentFields.fetch!(fields, "slug"), + excerpt: Map.get(fields, "excerpt"), + content: nil, + status: :published, + author: Map.get(fields, "author"), + language: Map.get(fields, "language", fallback_post.language), + do_not_translate: + DocumentFields.get( + fields, + "doNotTranslate", + fallback_post.do_not_translate || false + ), + template_slug: + DocumentFields.get(fields, "templateSlug", fallback_post.template_slug), + created_at: DocumentFields.get(fields, "createdAt", fallback_post.created_at), + updated_at: DocumentFields.get(fields, "updatedAt", fallback_post.updated_at), + published_at: DocumentFields.get(fields, "publishedAt", fallback_post.published_at), + file_path: fallback_post.file_path, + tags: Map.get(fields, "tags", fallback_post.tags || []), + categories: Map.get(fields, "categories", fallback_post.categories || []) } {:error, _reason} -> @@ -231,13 +273,20 @@ defmodule BDS.Generation.Data do end end - defp build_generation_route_posts(project_id, project_data_dir, published_posts, on_snapshot_progress) do + defp build_generation_route_posts( + project_id, + project_data_dir, + published_posts, + on_snapshot_progress + ) do source_post_ids = Enum.map(published_posts, & &1.id) translation_candidates = Repo.all( from translation in Translation, - where: translation.project_id == ^project_id and translation.translation_for in ^source_post_ids, + where: + translation.project_id == ^project_id and + translation.translation_for in ^source_post_ids, where: translation.status in [:published, :draft], order_by: [asc: translation.translation_for, asc: translation.language] ) @@ -246,7 +295,13 @@ defmodule BDS.Generation.Data do translation_candidates |> Enum.with_index(1) |> Enum.reduce(%{}, fn {translation, index}, acc -> - :ok = report_snapshot_stage_progress(on_snapshot_progress, :translations, index, length(translation_candidates)) + :ok = + report_snapshot_stage_progress( + on_snapshot_progress, + :translations, + index, + length(translation_candidates) + ) case published_translation_snapshot(project_data_dir, translation) do nil -> acc @@ -288,18 +343,20 @@ defmodule BDS.Generation.Data do {:ok, contents} -> {:ok, %{fields: fields}} = Frontmatter.parse_document(contents) - %Translation{fallback_translation | - id: DocumentFields.get(fields, "id", fallback_translation.id), - translation_for: DocumentFields.fetch!(fields, "translationFor"), - language: DocumentFields.fetch!(fields, "language"), - title: DocumentFields.get(fields, "title", fallback_translation.title) || "", - excerpt: Map.get(fields, "excerpt", fallback_translation.excerpt), - content: nil, - status: :published, - created_at: DocumentFields.get(fields, "createdAt", fallback_translation.created_at), - updated_at: DocumentFields.get(fields, "updatedAt", fallback_translation.updated_at), - published_at: DocumentFields.get(fields, "publishedAt", fallback_translation.published_at), - file_path: fallback_translation.file_path + %Translation{ + fallback_translation + | id: DocumentFields.get(fields, "id", fallback_translation.id), + translation_for: DocumentFields.fetch!(fields, "translationFor"), + language: DocumentFields.fetch!(fields, "language"), + title: DocumentFields.get(fields, "title", fallback_translation.title) || "", + excerpt: Map.get(fields, "excerpt", fallback_translation.excerpt), + content: nil, + status: :published, + created_at: DocumentFields.get(fields, "createdAt", fallback_translation.created_at), + updated_at: DocumentFields.get(fields, "updatedAt", fallback_translation.updated_at), + published_at: + DocumentFields.get(fields, "publishedAt", fallback_translation.published_at), + file_path: fallback_translation.file_path } {:error, _reason} -> diff --git a/lib/bds/generation/outputs.ex b/lib/bds/generation/outputs.ex index 00596a6..4bc87bf 100644 --- a/lib/bds/generation/outputs.ex +++ b/lib/bds/generation/outputs.ex @@ -25,8 +25,16 @@ defmodule BDS.Generation.Outputs do end) end - @spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [String.t()] - def build_validation_route_paths(plan, route_posts, published_list_posts, post_index, route_language) do + @spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [ + String.t() + ] + def build_validation_route_paths( + plan, + route_posts, + published_list_posts, + post_index, + route_language + ) do [ core_route_paths(plan, published_list_posts, route_language), page_route_paths(plan, route_posts, route_language), @@ -250,7 +258,9 @@ defmodule BDS.Generation.Outputs do Enum.flat_map(posts_by_tag, fn {tag, posts} -> tag_slug = archive_route_segment(tag) - build_paginated_archive_outputs(plan, languages, ["tag", tag_slug], posts, fn page_posts, language, pagination -> + build_paginated_archive_outputs(plan, languages, ["tag", tag_slug], posts, fn page_posts, + language, + pagination -> render_archive_page(plan, tag, page_posts, language, "tag", pagination) end) end) @@ -260,23 +270,31 @@ defmodule BDS.Generation.Outputs do def build_date_outputs(plan, post_index, languages) do year_outputs = Enum.flat_map(post_index.posts_by_year, fn {year, posts} -> - build_paginated_archive_outputs(plan, languages, [Integer.to_string(year)], posts, fn page_posts, language, pagination -> - render_date_archive_page( - plan, - Integer.to_string(year), - %{kind: "year", year: year}, - page_posts, - language, - pagination - ) - end) + build_paginated_archive_outputs( + plan, + languages, + [Integer.to_string(year)], + posts, + fn page_posts, language, pagination -> + render_date_archive_page( + plan, + Integer.to_string(year), + %{kind: "year", year: year}, + page_posts, + language, + pagination + ) + end + ) end) month_outputs = Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} -> [year, month] = String.split(year_month, "/", parts: 2) - build_paginated_archive_outputs(plan, languages, [year, month], posts, fn page_posts, language, pagination -> + build_paginated_archive_outputs(plan, languages, [year, month], posts, fn page_posts, + language, + pagination -> render_date_archive_page( plan, "#{year}-#{month}", @@ -292,11 +310,18 @@ defmodule BDS.Generation.Outputs do Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} -> [year, month, day] = String.split(year_month_day, "/", parts: 3) - build_paginated_archive_outputs(plan, languages, [year, month, day], posts, fn page_posts, language, pagination -> + build_paginated_archive_outputs(plan, languages, [year, month, day], posts, fn page_posts, + language, + pagination -> render_date_archive_page( plan, "#{year}-#{month}-#{day}", - %{kind: "day", year: String.to_integer(year), month: String.to_integer(month), day: String.to_integer(day)}, + %{ + kind: "day", + year: String.to_integer(year), + month: String.to_integer(month), + day: String.to_integer(day) + }, page_posts, language, pagination @@ -323,19 +348,32 @@ defmodule BDS.Generation.Outputs do Enum.flat_map(additional_languages, fn localized_language -> localized_prefix = route_language(plan.language, localized_language) localized_source_posts = Map.get(localized_posts_by_language, localized_language, []) - localized_posts = build_list_posts(plan.base_url, localized_source_posts, localized_prefix) + + localized_posts = + build_list_posts(plan.base_url, localized_source_posts, localized_prefix) build_root_outputs(plan, localized_language, localized_posts) ++ [ - {Path.join(localized_language, "404.html"), render_not_found_output(plan, localized_language)}, - {Path.join(localized_language, "feed.xml"), render_feed(plan, localized_language, localized_source_posts)}, - {Path.join(localized_language, "atom.xml"), render_atom(plan, localized_language, localized_source_posts)} + {Path.join(localized_language, "404.html"), + render_not_found_output(plan, localized_language)}, + {Path.join(localized_language, "feed.xml"), + render_feed(plan, localized_language, localized_source_posts)}, + {Path.join(localized_language, "atom.xml"), + render_atom(plan, localized_language, localized_source_posts)} ] end) end - @spec build_page_outputs(String.t(), String.t(), [map()], map(), map()) :: [{String.t(), iodata()}] - def build_page_outputs(project_id, main_language, published_posts, translations_by_post_language, localized_posts_by_language) do + @spec build_page_outputs(String.t(), String.t(), [map()], map(), map()) :: [ + {String.t(), iodata()} + ] + def build_page_outputs( + project_id, + main_language, + published_posts, + translations_by_post_language, + localized_posts_by_language + ) do page_outputs = published_posts |> Enum.filter(&("page" in (&1.categories || []))) @@ -355,7 +393,14 @@ defmodule BDS.Generation.Outputs do language: canonical_variant.language, excerpt: canonical_variant.excerpt }, - fn -> render_post_page(canonical_variant.title, body, post.slug, canonical_variant.language) end + fn -> + render_post_page( + canonical_variant.title, + body, + post.slug, + canonical_variant.language + ) + end )} end) @@ -404,13 +449,22 @@ defmodule BDS.Generation.Outputs do plan.project_name, page_posts, %{kind: "core"}, - pagination_for_page(page_number, total_pages, length(posts), plan.max_posts_per_page, route_language, []), + pagination_for_page( + page_number, + total_pages, + length(posts), + plan.max_posts_per_page, + route_language, + [] + ), fn -> render_home(plan, language) end )} end) end - @spec build_paginated_archive_outputs(map(), [String.t()], [String.t()], [map()], (... -> iodata())) :: [{String.t(), iodata()}] + @spec build_paginated_archive_outputs(map(), [String.t()], [String.t()], [map()], (... -> + iodata())) :: + [{String.t(), iodata()}] def build_paginated_archive_outputs(plan, languages, segments, posts, render_fun) do total_pages = page_count(length(posts), plan.max_posts_per_page) @@ -425,13 +479,22 @@ defmodule BDS.Generation.Outputs do render_fun.( page_posts, language, - pagination_for_page(page_number, total_pages, length(posts), plan.max_posts_per_page, route_language, segments) + pagination_for_page( + page_number, + total_pages, + length(posts), + plan.max_posts_per_page, + route_language, + segments + ) )} end) end) end - @spec build_single_outputs(String.t(), String.t(), [map()], map(), map()) :: [{String.t(), iodata()}] + @spec build_single_outputs(String.t(), String.t(), [map()], map(), map()) :: [ + {String.t(), iodata()} + ] def build_single_outputs( project_id, main_language, @@ -457,7 +520,12 @@ defmodule BDS.Generation.Outputs do excerpt: canonical_variant.excerpt }, fn -> - render_post_page(canonical_variant.title, body, post.slug, canonical_variant.language) + render_post_page( + canonical_variant.title, + body, + post.slug, + canonical_variant.language + ) end )} end) diff --git a/lib/bds/generation/pagefind.ex b/lib/bds/generation/pagefind.ex index 3a43ca2..65841ce 100644 --- a/lib/bds/generation/pagefind.ex +++ b/lib/bds/generation/pagefind.ex @@ -19,10 +19,13 @@ defmodule BDS.Generation.Pagefind do |> Enum.flat_map(fn language -> route_language = route_language(plan.language, language) pages = pages_for_language(html_outputs, route_language) - prefix = if route_language in [nil, ""], do: ["pagefind"], else: [route_language, "pagefind"] + + prefix = + if route_language in [nil, ""], do: ["pagefind"], else: [route_language, "pagefind"] [ - {Path.join(prefix ++ ["index.json"]), Jason.encode!(%{"language" => language, "pages" => pages})}, + {Path.join(prefix ++ ["index.json"]), + Jason.encode!(%{"language" => language, "pages" => pages})}, {Path.join(prefix ++ ["pagefind-ui.js"]), ui_js(language)}, {Path.join(prefix ++ ["pagefind-ui.css"]), ui_css()} ] diff --git a/lib/bds/generation/paths.ex b/lib/bds/generation/paths.ex index c2b9304..0f35c5b 100644 --- a/lib/bds/generation/paths.ex +++ b/lib/bds/generation/paths.ex @@ -49,18 +49,37 @@ defmodule BDS.Generation.Paths do def root_output_path(nil, 1), do: "index.html" def root_output_path("", 1), do: "index.html" def root_output_path(route_language, 1), do: Path.join(route_language, "index.html") - def root_output_path(nil, page_number), do: Path.join(["page", Integer.to_string(page_number), "index.html"]) + + def root_output_path(nil, page_number), + do: Path.join(["page", Integer.to_string(page_number), "index.html"]) + def root_output_path("", page_number), do: root_output_path(nil, page_number) - def root_output_path(route_language, page_number), do: Path.join([route_language, "page", Integer.to_string(page_number), "index.html"]) + + def root_output_path(route_language, page_number), + do: Path.join([route_language, "page", Integer.to_string(page_number), "index.html"]) @spec page_output_path(String.t(), language()) :: String.t() def page_output_path(slug, nil), do: Path.join([slug, "index.html"]) def page_output_path(slug, ""), do: page_output_path(slug, nil) def page_output_path(slug, language), do: Path.join([language, slug, "index.html"]) - @spec pagination_for_page(pos_integer(), pos_integer(), non_neg_integer(), pos_integer(), language(), [String.t()]) :: + @spec pagination_for_page( + pos_integer(), + pos_integer(), + non_neg_integer(), + pos_integer(), + language(), + [String.t()] + ) :: map() - def pagination_for_page(page_number, total_pages, total_items, items_per_page, route_language, segments) do + def pagination_for_page( + page_number, + total_pages, + total_items, + items_per_page, + route_language, + segments + ) do %{ current_page: page_number, total_pages: total_pages, @@ -75,8 +94,12 @@ defmodule BDS.Generation.Paths do @spec archive_or_root_href(language(), [String.t()], integer()) :: String.t() def archive_or_root_href(_route_language, _segments, page_number) when page_number < 1, do: "" - def archive_or_root_href(route_language, [], page_number), do: root_page_href(route_language, page_number) - def archive_or_root_href(route_language, segments, page_number), do: archive_href(route_language, segments, page_number) + + def archive_or_root_href(route_language, [], page_number), + do: root_page_href(route_language, page_number) + + def archive_or_root_href(route_language, segments, page_number), + do: archive_href(route_language, segments, page_number) @spec root_page_href(language(), integer()) :: String.t() def root_page_href(route_language, page_number) when page_number <= 1 do @@ -147,7 +170,9 @@ defmodule BDS.Generation.Paths do @spec archive_route_segment(any()) :: String.t() def archive_route_segment(nil), do: "" - def archive_route_segment(value), do: value |> to_string() |> URI.encode(&URI.char_unreserved?/1) + + def archive_route_segment(value), + do: value |> to_string() |> URI.encode(&URI.char_unreserved?/1) @spec normalize_base_url(String.t() | nil) :: String.t() | nil def normalize_base_url(nil), do: nil diff --git a/lib/bds/generation/renderers.ex b/lib/bds/generation/renderers.ex index 546ecfb..dc9a869 100644 --- a/lib/bds/generation/renderers.ex +++ b/lib/bds/generation/renderers.ex @@ -44,7 +44,8 @@ defmodule BDS.Generation.Renderers do end @doc "Render an archive page (category, tag, year) with pagination." - @spec render_archive_page(map(), String.t(), [map()], String.t() | nil, String.t(), map()) :: String.t() + @spec render_archive_page(map(), String.t(), [map()], String.t() | nil, String.t(), map()) :: + String.t() def render_archive_page(plan, title, posts, language, kind, pagination) do fallback = fn -> items = @@ -130,7 +131,15 @@ defmodule BDS.Generation.Renderers do end @doc "Render a list/archive page through the project template, falling back to inline." - @spec render_list_output(map(), String.t() | nil, String.t(), [map()], map(), map(), (-> String.t())) :: + @spec render_list_output( + map(), + String.t() | nil, + String.t(), + [map()], + map(), + map(), + (-> String.t()) + ) :: String.t() def render_list_output( %{project_id: project_id, language: main_language}, diff --git a/lib/bds/generation/sitemap.ex b/lib/bds/generation/sitemap.ex index 506344e..77cc6ca 100644 --- a/lib/bds/generation/sitemap.ex +++ b/lib/bds/generation/sitemap.ex @@ -34,17 +34,20 @@ defmodule BDS.Generation.Sitemap do build_hreflang_links(plan.base_url, "/", plan.language, all_languages) ) ] ++ - Enum.map(Paths.root_pagination_pages(length(published_list_posts), plan.max_posts_per_page), fn page_number -> - page_path = "/page/#{page_number}" + Enum.map( + Paths.root_pagination_pages(length(published_list_posts), plan.max_posts_per_page), + fn page_number -> + page_path = "/page/#{page_number}" - url_entry( - Paths.url_for_path(plan.base_url, page_path), - latest_post_updated_at, - "daily", - "0.9", - build_hreflang_links(plan.base_url, page_path, plan.language, all_languages) - ) - end) ++ + url_entry( + Paths.url_for_path(plan.base_url, page_path), + latest_post_updated_at, + "daily", + "0.9", + build_hreflang_links(plan.base_url, page_path, plan.language, all_languages) + ) + end + ) ++ Enum.map(translatable_posts, fn post -> post_path = Paths.relative_path_to_url_path(Paths.post_output_path(post)) @@ -100,28 +103,34 @@ defmodule BDS.Generation.Sitemap do build_hreflang_links(plan.base_url, year_path, plan.language, all_languages) ) end) ++ - Enum.map(Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc), fn {year_month, _posts} -> - month_path = "/#{year_month}" + Enum.map( + Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc), + fn {year_month, _posts} -> + month_path = "/#{year_month}" - url_entry( - Paths.url_for_path(plan.base_url, month_path), - latest_post_updated_at, - "monthly", - "0.5", - build_hreflang_links(plan.base_url, month_path, plan.language, all_languages) - ) - end) ++ - Enum.map(Enum.sort_by(post_index.posts_by_year_month_day, &elem(&1, 0), :desc), fn {year_month_day, _posts} -> - day_path = "/#{year_month_day}" + url_entry( + Paths.url_for_path(plan.base_url, month_path), + latest_post_updated_at, + "monthly", + "0.5", + build_hreflang_links(plan.base_url, month_path, plan.language, all_languages) + ) + end + ) ++ + Enum.map( + Enum.sort_by(post_index.posts_by_year_month_day, &elem(&1, 0), :desc), + fn {year_month_day, _posts} -> + day_path = "/#{year_month_day}" - url_entry( - Paths.url_for_path(plan.base_url, day_path), - latest_post_updated_at, - "monthly", - "0.4", - build_hreflang_links(plan.base_url, day_path, plan.language, all_languages) - ) - end) ++ + url_entry( + Paths.url_for_path(plan.base_url, day_path), + latest_post_updated_at, + "monthly", + "0.4", + build_hreflang_links(plan.base_url, day_path, plan.language, all_languages) + ) + end + ) ++ Enum.map(Enum.sort_by(post_index.posts_by_category, &elem(&1, 0)), fn {category, _posts} -> category_path = "/category/#{Paths.archive_route_segment(category)}" diff --git a/lib/bds/generation/validation.ex b/lib/bds/generation/validation.ex index 85b9627..9c28632 100644 --- a/lib/bds/generation/validation.ex +++ b/lib/bds/generation/validation.ex @@ -9,6 +9,7 @@ defmodule BDS.Generation.Validation do relative_path_to_url_path: 1, url_path_to_relative_index_path: 1 ] + import BDS.Generation.Progress, only: [report_validation_compare_progress: 3] import BDS.Generation.Sitemap, only: [extract_locs: 1, loc_to_project_path: 2] @@ -20,7 +21,11 @@ defmodule BDS.Generation.Validation do end @spec build_post_timestamp_checks(String.t(), [map()], map()) :: [map()] - def build_post_timestamp_checks(project_data_dir, published_route_posts, generated_file_updated_at) do + def build_post_timestamp_checks( + project_data_dir, + published_route_posts, + generated_file_updated_at + ) do Enum.map(published_route_posts, fn post -> relative_path = BDS.Generation.Paths.post_output_path(post) @@ -69,13 +74,19 @@ defmodule BDS.Generation.Validation do |> Enum.map(&loc_to_project_path(&1, params.base_url)) |> Enum.reduce(MapSet.new(), &MapSet.put(&2, normalize_url_path(&1))) |> then(fn expected_paths -> - Enum.reduce(Map.get(params, :additional_expected_paths, []), expected_paths, fn path, acc -> + Enum.reduce(Map.get(params, :additional_expected_paths, []), expected_paths, fn path, + acc -> MapSet.put(acc, normalize_url_path(path)) end) end) {existing_html_path_set, zero_byte_html_path_set} = - collect_html_index_paths(index_paths, params.html_dir, params.on_progress, total_compare_steps) + collect_html_index_paths( + index_paths, + params.html_dir, + params.on_progress, + total_compare_steps + ) missing_url_paths = expected_path_set @@ -119,11 +130,14 @@ defmodule BDS.Generation.Validation do acc true -> - html_path = Path.join(params.html_dir, url_path_to_relative_index_path(normalized_url_path)) + html_path = + Path.join(params.html_dir, url_path_to_relative_index_path(normalized_url_path)) - case {File.stat(html_path, time: :posix), File.stat(check.post_file_path, time: :posix)} do + case {File.stat(html_path, time: :posix), + File.stat(check.post_file_path, time: :posix)} do {{:ok, html_stat}, {:ok, post_stat}} -> - effective_generated_at_ms = max(mtime_ms(html_stat), check.generated_updated_at_ms || 0) + effective_generated_at_ms = + max(mtime_ms(html_stat), check.generated_updated_at_ms || 0) if mtime_ms(post_stat) > effective_generated_at_ms do MapSet.put(acc, normalized_url_path) @@ -233,7 +247,18 @@ defmodule BDS.Generation.Validation do nil -> case Regex.run(~r|^/(\d{4})/(\d{2})/(\d{2})/([^/]+)$|, path) do [_, year, month, day, slug] -> - update_in(plan.requested_post_routes, &[ %{year: String.to_integer(year), month: String.to_integer(month), day: String.to_integer(day), slug: slug} | &1 ]) + update_in( + plan.requested_post_routes, + &[ + %{ + year: String.to_integer(year), + month: String.to_integer(month), + day: String.to_integer(day), + slug: slug + } + | &1 + ] + ) nil -> case Regex.run(~r|^/(\d{4})/(\d{2})(?:/page/\d+)?$|, path) do @@ -281,29 +306,43 @@ defmodule BDS.Generation.Validation do end) enriched = - Enum.reduce(initial_plan.requested_post_routes, %{initial_plan | requested_post_routes: targeted_post_routes}, fn route, acc -> - case Enum.find(published_posts, &post_matches_route?(&1, route)) do - nil -> - acc - |> update_in([:requested_years], &MapSet.put(&1, route.year)) - |> update_in([:requested_year_months], &MapSet.put(&1, route_month_key(route.year, route.month))) - |> Map.put(:request_root_routes, true) + Enum.reduce( + initial_plan.requested_post_routes, + %{initial_plan | requested_post_routes: targeted_post_routes}, + fn route, acc -> + case Enum.find(published_posts, &post_matches_route?(&1, route)) do + nil -> + acc + |> update_in([:requested_years], &MapSet.put(&1, route.year)) + |> update_in( + [:requested_year_months], + &MapSet.put(&1, route_month_key(route.year, route.month)) + ) + |> Map.put(:request_root_routes, true) - post -> - {year, month, _day} = local_date_parts!(post.created_at) + post -> + {year, month, _day} = local_date_parts!(post.created_at) - acc - |> update_in([:requested_category_slugs], fn set -> - Enum.reduce(post.categories || [], set, &MapSet.put(&2, archive_route_segment(&1))) - end) - |> update_in([:requested_tag_slugs], fn set -> - Enum.reduce(post.tags || [], set, &MapSet.put(&2, archive_route_segment(&1))) - end) - |> update_in([:requested_years], &MapSet.put(&1, year)) - |> update_in([:requested_year_months], &MapSet.put(&1, route_month_key(year, month))) - |> Map.put(:request_root_routes, true) + acc + |> update_in([:requested_category_slugs], fn set -> + Enum.reduce( + post.categories || [], + set, + &MapSet.put(&2, archive_route_segment(&1)) + ) + end) + |> update_in([:requested_tag_slugs], fn set -> + Enum.reduce(post.tags || [], set, &MapSet.put(&2, archive_route_segment(&1))) + end) + |> update_in([:requested_years], &MapSet.put(&1, year)) + |> update_in( + [:requested_year_months], + &MapSet.put(&1, route_month_key(year, month)) + ) + |> Map.put(:request_root_routes, true) + end end - end) + ) language_plans = initial_plan.language_plans @@ -314,8 +353,10 @@ defmodule BDS.Generation.Validation do %{ enriched - | requested_category_slugs: MapSet.intersection(enriched.requested_category_slugs, available_category_slugs), - requested_tag_slugs: MapSet.intersection(enriched.requested_tag_slugs, available_tag_slugs), + | requested_category_slugs: + MapSet.intersection(enriched.requested_category_slugs, available_category_slugs), + requested_tag_slugs: + MapSet.intersection(enriched.requested_tag_slugs, available_tag_slugs), language_plans: language_plans } end @@ -351,13 +392,15 @@ defmodule BDS.Generation.Validation do {nil, path} end - _other -> {nil, path} + _other -> + {nil, path} end end @spec targeted_output?(String.t(), map(), String.t() | nil, [String.t()]) :: boolean() def targeted_output?(relative_path, targeted_plan, main_language, additional_languages) do - {language, stripped_path} = extract_relative_output_language(relative_path, additional_languages) + {language, stripped_path} = + extract_relative_output_language(relative_path, additional_languages) plan = case language do @@ -384,7 +427,11 @@ defmodule BDS.Generation.Validation do end end - defp targeted_output_for_plan?(_relative_path, %{requires_fallback_section_render: true}, _main?), do: true + defp targeted_output_for_plan?( + _relative_path, + %{requires_fallback_section_render: true}, + _main? + ), do: true defp targeted_output_for_plan?(relative_path, plan, _main?) do cond do @@ -400,8 +447,18 @@ defmodule BDS.Generation.Validation do MapSet.member?(plan.requested_tag_slugs, slug) Regex.match?(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path) -> - [_, year, month, day, slug] = Regex.run(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path) - MapSet.member?(plan.requested_post_routes, route_key(String.to_integer(year), String.to_integer(month), String.to_integer(day), slug)) + [_, year, month, day, slug] = + Regex.run(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path) + + MapSet.member?( + plan.requested_post_routes, + route_key( + String.to_integer(year), + String.to_integer(month), + String.to_integer(day), + slug + ) + ) Regex.match?(~r|^(\d{4})/(\d{2})/index\.html$|, relative_path) -> [_, year, month] = Regex.run(~r|^(\d{4})/(\d{2})/index\.html$|, relative_path) diff --git a/lib/bds/git.ex b/lib/bds/git.ex index fa9e997..47f0cd1 100644 --- a/lib/bds/git.ex +++ b/lib/bds/git.ex @@ -59,7 +59,9 @@ defmodule BDS.Git do has_lfs: has_lfs_configured?(project_dir) }} else - {:error, :not_found} = error -> error + {:error, :not_found} = error -> + error + {:error, _reason} -> {:ok, %{ @@ -74,7 +76,8 @@ defmodule BDS.Git do def status(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do with {:ok, project_dir} <- project_dir(project_id), - {:ok, output} <- run_git(project_dir, ["status", "--porcelain=v1", "--untracked-files=all"], opts) do + {:ok, output} <- + run_git(project_dir, ["status", "--porcelain=v1", "--untracked-files=all"], opts) do {:ok, %{files: parse_status(output)}} end end @@ -112,7 +115,8 @@ defmodule BDS.Git do when is_binary(project_id) and is_binary(branch) and is_list(opts) do with {:ok, project_dir} <- project_dir(project_id), {:ok, local_log} <- run_git(project_dir, ["log", "--format=%H%x09%s", branch], opts), - {:ok, remote_log} <- run_git(project_dir, ["log", "--format=%H", "origin/#{branch}"], opts) do + {:ok, remote_log} <- + run_git(project_dir, ["log", "--format=%H", "origin/#{branch}"], opts) do local_commits = parse_local_history(local_log) remote_hashes = MapSet.new(parse_remote_history(remote_log)) local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash)) @@ -121,7 +125,9 @@ defmodule BDS.Git do remote_hashes |> MapSet.difference(local_hashes) |> MapSet.to_list() - |> Enum.map(fn hash -> %{hash: hash, subject: nil, sync_status: %{kind: :remote_only}} end) + |> Enum.map(fn hash -> + %{hash: hash, subject: nil, sync_status: %{kind: :remote_only}} + end) commits = Enum.map(local_commits, fn commit -> @@ -136,7 +142,8 @@ defmodule BDS.Git do def file_history(project_id, file_path, opts \\ []) when is_binary(project_id) and is_binary(file_path) and is_list(opts) do with {:ok, project_dir} <- project_dir(project_id), - {:ok, output} <- run_git(project_dir, ["log", "--follow", "--format=%H%x09%s", "--", file_path], opts) do + {:ok, output} <- + run_git(project_dir, ["log", "--follow", "--format=%H%x09%s", "--", file_path], opts) do {:ok, %{commits: parse_local_history(output) |> Enum.take(50)}} else {:error, {:git_failed, _message}} -> {:ok, %{commits: []}} @@ -147,8 +154,11 @@ defmodule BDS.Git do def fetch(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do with {:ok, project_dir} <- project_dir(project_id) do case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do - {:ok, output} -> {:ok, %{updated: true, output: output}} - {:error, {:git_failed, message}} -> structured_git_error(project_dir, :fetch, message, opts) + {:ok, output} -> + {:ok, %{updated: true, output: output}} + + {:error, {:git_failed, message}} -> + structured_git_error(project_dir, :fetch, message, opts) end end end @@ -177,9 +187,11 @@ defmodule BDS.Git do end def reconcile(project_id, old_commit, new_commit, opts \\ []) - when is_binary(project_id) and is_binary(old_commit) and is_binary(new_commit) and is_list(opts) do + when is_binary(project_id) and is_binary(old_commit) and is_binary(new_commit) and + is_list(opts) do with {:ok, project_dir} <- project_dir(project_id), - {:ok, output} <- run_git(project_dir, ["diff", "--name-status", old_commit, new_commit], opts) do + {:ok, output} <- + run_git(project_dir, ["diff", "--name-status", old_commit, new_commit], opts) do {:ok, %{changed: parse_changed_files(output)}} end end @@ -197,7 +209,14 @@ defmodule BDS.Git do {:ok, local_branch} <- current_branch(project_dir, opts) do case upstream_branch(project_dir, opts) do {:ok, nil} -> - {:ok, %{local_branch: local_branch, upstream_branch: nil, has_upstream: false, ahead: 0, behind: 0}} + {:ok, + %{ + local_branch: local_branch, + upstream_branch: nil, + has_upstream: false, + ahead: 0, + behind: 0 + }} {:ok, upstream_branch} -> {:ok, @@ -316,7 +335,11 @@ defmodule BDS.Git do end defp upstream_branch(project_dir, opts) do - case run_git(project_dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], opts) do + case run_git( + project_dir, + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + opts + ) do {:ok, output} -> {:ok, blank_to_nil(output)} {:error, {:git_failed, _message}} -> {:ok, nil} end @@ -364,21 +387,37 @@ defmodule BDS.Git do defp parse_changed_files(output) do base = fn -> %{added: [], modified: [], deleted: [], renamed: []} end - Enum.reduce(String.split(output, "\n", trim: true), %{posts: base.(), scripts: base.(), templates: base.()}, fn line, acc -> - case String.split(line, "\t", trim: true) do - ["A", path] -> update_changed(acc, path, :added, path) - ["M", path] -> update_changed(acc, path, :modified, path) - ["D", path] -> update_changed(acc, path, :deleted, path) - ["R" <> _score, old_path, new_path] -> update_changed(acc, new_path, :renamed, %{old: old_path, new: new_path}) - _other -> acc + Enum.reduce( + String.split(output, "\n", trim: true), + %{posts: base.(), scripts: base.(), templates: base.()}, + fn line, acc -> + case String.split(line, "\t", trim: true) do + ["A", path] -> + update_changed(acc, path, :added, path) + + ["M", path] -> + update_changed(acc, path, :modified, path) + + ["D", path] -> + update_changed(acc, path, :deleted, path) + + ["R" <> _score, old_path, new_path] -> + update_changed(acc, new_path, :renamed, %{old: old_path, new: new_path}) + + _other -> + acc + end end - end) + ) end defp update_changed(acc, path, key, value) do case category_for_path(path) do - nil -> acc - category -> Map.update!(acc, category, &Map.update!(&1, key, fn items -> items ++ [value] end)) + nil -> + acc + + category -> + Map.update!(acc, category, &Map.update!(&1, key, fn items -> items ++ [value] end)) end end @@ -427,6 +466,7 @@ defmodule BDS.Git do defp auth_guidance(provider, platform) do provider_label = provider || :git + "Authentication failed for #{provider_label} on #{platform}. Configure SSH keys or a credential helper that works non-interactively." end diff --git a/lib/bds/import_analysis.ex b/lib/bds/import_analysis.ex index 8cbae95..96e0bf9 100644 --- a/lib/bds/import_analysis.ex +++ b/lib/bds/import_analysis.ex @@ -32,11 +32,23 @@ defmodule BDS.ImportAnalysis do notify_progress(on_progress, "Loading existing posts...") existing_posts = Repo.all(from post in Post, where: post.project_id == ^project_id) - notify_progress(on_progress, "Loading existing media...", "#{length(existing_posts)} posts in project") + notify_progress( + on_progress, + "Loading existing media...", + "#{length(existing_posts)} posts in project" + ) + existing_media = Repo.all(from media in Media, where: media.project_id == ^project_id) - notify_progress(on_progress, "Loading existing tags...", "#{length(existing_media)} media in project") - existing_tag_names = Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: tag.name) + notify_progress( + on_progress, + "Loading existing tags...", + "#{length(existing_media)} media in project" + ) + + existing_tag_names = + Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: tag.name) + existing_tag_set = existing_tag_names |> Enum.map(&String.downcase/1) |> MapSet.new() posts_by_slug = Map.new(existing_posts, &{&1.slug, &1}) @@ -53,15 +65,35 @@ defmodule BDS.ImportAnalysis do |> Enum.reject(&is_nil(&1.checksum)) |> Map.new(&{&1.checksum, &1}) - notify_progress(on_progress, "Analyzing posts...", "#{length(wxr_data.posts)} posts to analyze") - analyzed_posts = Enum.map(wxr_data.posts, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "post")) + notify_progress( + on_progress, + "Analyzing posts...", + "#{length(wxr_data.posts)} posts to analyze" + ) - notify_progress(on_progress, "Analyzing pages...", "#{length(wxr_data.pages)} pages to analyze") - analyzed_pages = Enum.map(wxr_data.pages, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "page")) + analyzed_posts = + Enum.map(wxr_data.posts, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "post")) + + notify_progress( + on_progress, + "Analyzing pages...", + "#{length(wxr_data.pages)} pages to analyze" + ) + + analyzed_pages = + Enum.map(wxr_data.pages, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "page")) + + notify_progress( + on_progress, + "Analyzing media files...", + "#{length(wxr_data.media)} media files to analyze" + ) - notify_progress(on_progress, "Analyzing media files...", "#{length(wxr_data.media)} media files to analyze") analyzed_media = - Enum.map(wxr_data.media, &analyze_media_item(&1, uploads_folder_path, media_by_name, media_by_checksum)) + Enum.map( + wxr_data.media, + &analyze_media_item(&1, uploads_folder_path, media_by_name, media_by_checksum) + ) notify_progress(on_progress, "Processing categories and tags...") category_items = Enum.map(wxr_data.categories, &analyze_taxonomy_item(&1, existing_tag_set)) @@ -113,10 +145,18 @@ defmodule BDS.ImportAnalysis do {status, existing} = cond do - existing_by_slug && existing_by_slug.checksum == content_checksum && not is_nil(existing_by_slug.checksum) -> {"update", existing_by_slug} - existing_by_slug -> {"conflict", existing_by_slug} - existing_by_checksum -> {"content-duplicate", existing_by_checksum} - true -> {"new", nil} + existing_by_slug && existing_by_slug.checksum == content_checksum && + not is_nil(existing_by_slug.checksum) -> + {"update", existing_by_slug} + + existing_by_slug -> + {"conflict", existing_by_slug} + + existing_by_checksum -> + {"content-duplicate", existing_by_checksum} + + true -> + {"new", nil} end %{ @@ -163,10 +203,18 @@ defmodule BDS.ImportAnalysis do existing_by_checksum = Map.get(media_by_checksum, file_checksum) cond do - existing_by_name && existing_by_name.checksum == file_checksum && not is_nil(existing_by_name.checksum) -> {"update", file_checksum, existing_by_name} - existing_by_name -> {"conflict", file_checksum, existing_by_name} - existing_by_checksum -> {"content-duplicate", file_checksum, existing_by_checksum} - true -> {"new", file_checksum, nil} + existing_by_name && existing_by_name.checksum == file_checksum && + not is_nil(existing_by_name.checksum) -> + {"update", file_checksum, existing_by_name} + + existing_by_name -> + {"conflict", file_checksum, existing_by_name} + + existing_by_checksum -> + {"content-duplicate", file_checksum, existing_by_checksum} + + true -> + {"new", file_checksum, nil} end end @@ -265,7 +313,9 @@ defmodule BDS.ImportAnalysis do defp date_distribution(posts, pages, media) do combined_posts = posts ++ pages - post_counts = Enum.reduce(combined_posts, %{}, &increment_year(&1.created_at || &1.published_at, &2)) + post_counts = + Enum.reduce(combined_posts, %{}, &increment_year(&1.created_at || &1.published_at, &2)) + media_counts = Enum.reduce(media, %{}, &increment_year(&1.created_at, &2)) post_counts @@ -325,7 +375,10 @@ defmodule BDS.ImportAnalysis do | total_count: existing.total_count + 1, usages: Map.put(existing.usages, params_key, usage), post_slugs: - if(is_binary(slug), do: MapSet.put(existing.post_slugs, slug), else: existing.post_slugs) + if(is_binary(slug), + do: MapSet.put(existing.post_slugs, slug), + else: existing.post_slugs + ) } Map.put(inner_acc, name, updated) @@ -393,9 +446,17 @@ defmodule BDS.ImportAnalysis do defp year_from(value) when is_integer(value) do cond do - value > 100_000_000_000 -> value |> DateTime.from_unix!(:millisecond) |> DateTime.shift_zone!("Etc/UTC") |> Map.get(:year) - value > 1_000_000_000 -> value |> DateTime.from_unix!(:second) |> Map.get(:year) - true -> value + value > 100_000_000_000 -> + value + |> DateTime.from_unix!(:millisecond) + |> DateTime.shift_zone!("Etc/UTC") + |> Map.get(:year) + + value > 1_000_000_000 -> + value |> DateTime.from_unix!(:second) |> Map.get(:year) + + true -> + value end rescue _error -> nil @@ -405,10 +466,14 @@ defmodule BDS.ImportAnalysis do normalized = String.replace(value, " ", "T") case NaiveDateTime.from_iso8601(normalized) do - {:ok, naive} -> naive.year + {:ok, naive} -> + naive.year + _other -> case DateTime.from_iso8601(value) do - {:ok, datetime, _offset} -> datetime.year + {:ok, datetime, _offset} -> + datetime.year + _ -> case Regex.run(~r/(\d{4})/, value) do [_, year] -> String.to_integer(year) diff --git a/lib/bds/import_definitions.ex b/lib/bds/import_definitions.ex index 7ba3423..2930c5f 100644 --- a/lib/bds/import_definitions.ex +++ b/lib/bds/import_definitions.ex @@ -39,7 +39,10 @@ defmodule BDS.ImportDefinitions do |> maybe_put(:name, attr(attrs, :name)) |> maybe_put(:wxr_file_path, attr(attrs, :wxr_file_path)) |> maybe_put(:uploads_folder_path, attr(attrs, :uploads_folder_path)) - |> maybe_put(:last_analysis_result, normalize_analysis_result(attr(attrs, :last_analysis_result))) + |> maybe_put( + :last_analysis_result, + normalize_analysis_result(attr(attrs, :last_analysis_result)) + ) |> Map.put(:updated_at, Persistence.now_ms()) definition @@ -50,7 +53,9 @@ defmodule BDS.ImportDefinitions do def delete_definition(definition_id) when is_binary(definition_id) do case Repo.get(ImportDefinition, definition_id) do - nil -> {:error, :not_found} + nil -> + {:error, :not_found} + %ImportDefinition{} = definition -> Repo.delete(definition) |> case do @@ -60,7 +65,8 @@ defmodule BDS.ImportDefinitions do end end - def decode_analysis_result(%ImportDefinition{last_analysis_result: result}), do: decode_analysis_result(result) + def decode_analysis_result(%ImportDefinition{last_analysis_result: result}), + do: decode_analysis_result(result) def decode_analysis_result(result) when is_binary(result) do case Jason.decode(result) do diff --git a/lib/bds/import_definitions/import_definition.ex b/lib/bds/import_definitions/import_definition.ex index fe9b4b4..4db468d 100644 --- a/lib/bds/import_definitions/import_definition.ex +++ b/lib/bds/import_definitions/import_definition.ex @@ -19,7 +19,16 @@ defmodule BDS.ImportDefinitions.ImportDefinition do def changeset(definition, attrs) do definition - |> cast(attrs, [:id, :project_id, :name, :wxr_file_path, :uploads_folder_path, :last_analysis_result, :created_at, :updated_at]) + |> cast(attrs, [ + :id, + :project_id, + :name, + :wxr_file_path, + :uploads_folder_path, + :last_analysis_result, + :created_at, + :updated_at + ]) |> validate_required([:id, :project_id, :name, :created_at, :updated_at]) end end diff --git a/lib/bds/import_execution.ex b/lib/bds/import_execution.ex index 836a69a..1dbcbb4 100644 --- a/lib/bds/import_execution.ex +++ b/lib/bds/import_execution.ex @@ -8,7 +8,8 @@ defmodule BDS.ImportExecution do alias BDS.Repo alias BDS.Tags - def execute_import(project_id, report, opts \\ []) when is_binary(project_id) and is_map(report) do + def execute_import(project_id, report, opts \\ []) + when is_binary(project_id) and is_map(report) do normalized_report = normalize_report(report) default_author = Keyword.get(opts, :default_author) || project_default_author(project_id) uploads_folder_path = Keyword.get(opts, :uploads_folder_path) @@ -42,16 +43,52 @@ defmodule BDS.ImportExecution do started_at = System.monotonic_time(:millisecond) notify_progress(on_progress, "tags", 0, taxonomy_total, "creating_tags", started_at) - result = execute_taxonomies(category_items, tag_items, project_id, result, on_progress, started_at) + + result = + execute_taxonomies(category_items, tag_items, project_id, result, on_progress, started_at) notify_progress(on_progress, "posts", 0, length(post_items), "importing_posts", started_at) - result = execute_posts(post_items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, :posts, started_at) + + result = + execute_posts( + post_items, + project_id, + default_author, + tag_mapping, + category_mapping, + result, + on_progress, + :posts, + started_at + ) notify_progress(on_progress, "media", 0, length(media_items), "importing_media", started_at) - result = execute_media(media_items, project_id, default_author, result, on_progress, uploads_folder_path, started_at) + + result = + execute_media( + media_items, + project_id, + default_author, + result, + on_progress, + uploads_folder_path, + started_at + ) notify_progress(on_progress, "pages", 0, length(page_items), "importing_pages", started_at) - result = execute_posts(page_items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, :pages, started_at) + + result = + execute_posts( + page_items, + project_id, + default_author, + tag_mapping, + category_mapping, + result, + on_progress, + :pages, + started_at + ) notify_progress(on_progress, "complete", 1, 1, "import_complete", started_at) {:ok, result} @@ -68,41 +105,99 @@ defmodule BDS.ImportExecution do |> Enum.reduce(result, fn {item, index}, acc -> cond do Map.get(item, :exists_in_project) || not is_nil(Map.get(item, :mapped_to)) -> - notify_progress(on_progress, "tags", index, total, "skipped_tag:#{item.name}", started_at) + notify_progress( + on_progress, + "tags", + index, + total, + "skipped_tag:#{item.name}", + started_at + ) + put_in(acc, [:tags, :skipped], acc.tags.skipped + 1) true -> case Tags.create_tag(%{project_id: project_id, name: item.name}) do {:ok, _tag} -> - notify_progress(on_progress, "tags", index, total, "created_tag:#{item.name}", started_at) + notify_progress( + on_progress, + "tags", + index, + total, + "created_tag:#{item.name}", + started_at + ) + put_in(acc, [:tags, :created], acc.tags.created + 1) {:error, _reason} -> - notify_progress(on_progress, "tags", index, total, "skipped_tag:#{item.name}", started_at) + notify_progress( + on_progress, + "tags", + index, + total, + "skipped_tag:#{item.name}", + started_at + ) + put_in(acc, [:tags, :skipped], acc.tags.skipped + 1) end end end) end - defp execute_posts(items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, bucket, started_at) do + defp execute_posts( + items, + project_id, + default_author, + tag_mapping, + category_mapping, + result, + on_progress, + bucket, + started_at + ) do total = length(items) phase = Atom.to_string(bucket) Enum.with_index(items, 1) |> Enum.reduce(result, fn {item, index}, acc -> notify_progress(on_progress, phase, index, total, "processing:#{item.title}", started_at) - execute_post_item(project_id, maybe_apply_page_category(item, bucket), acc, bucket, default_author, tag_mapping, category_mapping) + + execute_post_item( + project_id, + maybe_apply_page_category(item, bucket), + acc, + bucket, + default_author, + tag_mapping, + category_mapping + ) end) end - defp execute_media(items, project_id, default_author, result, on_progress, uploads_folder_path, started_at) do + defp execute_media( + items, + project_id, + default_author, + result, + on_progress, + uploads_folder_path, + started_at + ) do total = length(items) items |> Enum.with_index(1) |> Enum.reduce(result, fn {item, index}, acc -> - notify_progress(on_progress, "media", index, total, "processing:#{item.filename}", started_at) + notify_progress( + on_progress, + "media", + index, + total, + "processing:#{item.filename}", + started_at + ) cond do item.status == "missing" -> @@ -116,7 +211,9 @@ defmodule BDS.ImportExecution do true -> case import_media_item(project_id, item, default_author, uploads_folder_path, acc) do - {:ok, _media} -> put_in(acc, [:media, :imported], acc.media.imported + 1) + {:ok, _media} -> + put_in(acc, [:media, :imported], acc.media.imported + 1) + {:error, reason} -> acc |> put_in([:media, :errors], acc.media.errors + 1) @@ -127,7 +224,15 @@ defmodule BDS.ImportExecution do end) end - defp execute_post_item(project_id, item, result, bucket, default_author, tag_mapping, category_mapping) do + defp execute_post_item( + project_id, + item, + result, + bucket, + default_author, + tag_mapping, + category_mapping + ) do cond do item.status in ["update", "content-duplicate", "duplicate"] -> put_in(result, [bucket, :skipped], get_in(result, [bucket, :skipped]) + 1) @@ -177,7 +282,8 @@ defmodule BDS.ImportExecution do defp overwrite_post_item(item, default_author, tag_mapping, category_mapping) do case Repo.get(Post, item.existing_id) do - nil -> {:error, :not_found} + nil -> + {:error, :not_found} %Post{} = post -> Posts.update_post(post.id, %{ @@ -194,7 +300,13 @@ defmodule BDS.ImportExecution do defp import_media_item(project_id, item, default_author, uploads_folder_path, result) do source_path = item.source_file || uploads_source_path(item.relative_path, uploads_folder_path) - checksum = if(source_path != nil and File.exists?(source_path), do: md5(File.read!(source_path)), else: nil) + + checksum = + if(source_path != nil and File.exists?(source_path), + do: md5(File.read!(source_path)), + else: nil + ) + linked_post_ids = parent_post_ids(item, result) if source_path && File.exists?(source_path) do @@ -221,7 +333,10 @@ defmodule BDS.ImportExecution do checksum: checksum } - attrs = if linked_post_ids == [], do: attrs, else: Map.put(attrs, :linked_post_ids, linked_post_ids) + attrs = + if linked_post_ids == [], + do: attrs, + else: Map.put(attrs, :linked_post_ids, linked_post_ids) case Media.import_media(attrs) do {:ok, %{id: media_id} = media} -> @@ -255,8 +370,12 @@ defmodule BDS.ImportExecution do defp parent_post_ids(item, result) do case Map.get(item, :parent_wp_id) do - nil -> [] - 0 -> [] + nil -> + [] + + 0 -> + [] + wp_id -> case Map.get(result.wp_id_to_post_id, wp_id) do nil -> [] @@ -265,7 +384,8 @@ defmodule BDS.ImportExecution do end end - defp track_wp_id(result, %{wp_id: wp_id}, %{id: post_id}) when is_integer(wp_id) and not is_nil(post_id) do + defp track_wp_id(result, %{wp_id: wp_id}, %{id: post_id}) + when is_integer(wp_id) and not is_nil(post_id) do update_in(result, [:wp_id_to_post_id], &Map.put(&1, wp_id, post_id)) end @@ -333,7 +453,9 @@ defmodule BDS.ImportExecution do end defp maybe_apply_page_category(item, :pages) do - categories = (Map.get(item, :categories) || []) |> Enum.uniq() |> Enum.concat(["page"]) |> Enum.uniq() + categories = + (Map.get(item, :categories) || []) |> Enum.uniq() |> Enum.concat(["page"]) |> Enum.uniq() + %{item | categories: categories} end @@ -349,7 +471,11 @@ defmodule BDS.ImportExecution do true -> key end - Map.put(acc, key, %{resolved: resolved, needs_creation: not item.exists_in_project and not present_string?(Map.get(item, :mapped_to))}) + Map.put(acc, key, %{ + resolved: resolved, + needs_creation: + not item.exists_in_project and not present_string?(Map.get(item, :mapped_to)) + }) end) end @@ -443,13 +569,15 @@ defmodule BDS.ImportExecution do defp uploads_source_path(relative_path, uploads_folder_path) defp uploads_source_path(relative_path, uploads_folder_path) - when is_binary(relative_path) and is_binary(uploads_folder_path) and uploads_folder_path != "" do + when is_binary(relative_path) and is_binary(uploads_folder_path) and + uploads_folder_path != "" do Path.join(uploads_folder_path, relative_path) end defp uploads_source_path(_relative_path, _uploads_folder_path), do: nil - defp notify_progress(callback, phase, current, total, detail, started_at) when is_function(callback, 4) do + defp notify_progress(callback, phase, current, total, detail, started_at) + when is_function(callback, 4) do eta = compute_eta(current, total, started_at) try do @@ -466,7 +594,9 @@ defmodule BDS.ImportExecution do :ok end - defp compute_eta(current, total, started_at) when is_integer(current) and is_integer(total) and current > 0 and total > 0 and current <= total do + defp compute_eta(current, total, started_at) + when is_integer(current) and is_integer(total) and current > 0 and total > 0 and + current <= total do elapsed = System.monotonic_time(:millisecond) - started_at if current >= total, do: 0, else: trunc(elapsed / current * (total - current)) end diff --git a/lib/bds/maintenance.ex b/lib/bds/maintenance.ex index 15c8a1d..72dfa9a 100644 --- a/lib/bds/maintenance.ex +++ b/lib/bds/maintenance.ex @@ -114,9 +114,11 @@ defmodule BDS.Maintenance do phases = [ {"Comparing project metadata", fn -> project_metadata_diff_reports(project_id) end}, {"Comparing post metadata", fn -> post_diff_reports(project_id, project) end}, - {"Comparing post translations", fn -> post_translation_diff_reports(project_id, project) end}, + {"Comparing post translations", + fn -> post_translation_diff_reports(project_id, project) end}, {"Comparing media metadata", fn -> media_diff_reports(project_id, project) end}, - {"Comparing media translations", fn -> media_translation_diff_reports(project_id, project) end}, + {"Comparing media translations", + fn -> media_translation_diff_reports(project_id, project) end}, {"Comparing script metadata", fn -> script_diff_reports(project_id, project) end}, {"Comparing template metadata", fn -> template_diff_reports(project_id, project) end}, {"Comparing embeddings", fn -> Embeddings.diff_reports(project_id) end} @@ -132,7 +134,9 @@ defmodule BDS.Maintenance do fun.() end) - :ok = report_metadata_diff_phase(on_progress, total_phases, total_phases, "Scanning orphan files") + :ok = + report_metadata_diff_phase(on_progress, total_phases, total_phases, "Scanning orphan files") + orphan_reports = orphan_reports(project_id, project) :ok = report_metadata_diff_complete(on_progress) diff --git a/lib/bds/maintenance/diff_computation.ex b/lib/bds/maintenance/diff_computation.ex index 56fa8e4..0dcc7a3 100644 --- a/lib/bds/maintenance/diff_computation.ex +++ b/lib/bds/maintenance/diff_computation.ex @@ -87,7 +87,10 @@ defmodule BDS.Maintenance.DiffComputation do end def normalize_nested_diff_value(value) when is_map(value), do: normalize_map_diff_values(value) - def normalize_nested_diff_value(value) when is_list(value), do: Enum.map(value, &normalize_nested_diff_value/1) + + def normalize_nested_diff_value(value) when is_list(value), + do: Enum.map(value, &normalize_nested_diff_value/1) + def normalize_nested_diff_value(value) when is_atom(value), do: Atom.to_string(value) def normalize_nested_diff_value(value), do: value end diff --git a/lib/bds/maintenance/diff_reports.ex b/lib/bds/maintenance/diff_reports.ex index 7afa845..33f590a 100644 --- a/lib/bds/maintenance/diff_reports.ex +++ b/lib/bds/maintenance/diff_reports.ex @@ -110,10 +110,18 @@ defmodule BDS.Maintenance.DiffReports do diff_field("author", post.author, Map.get(fields, "author")), diff_field("language", post.language, Map.get(fields, "language")), diff_field("status", post.status, DocumentFields.get(fields, "status")), - diff_field("template_slug", post.template_slug, DocumentFields.get(fields, "templateSlug")), + diff_field( + "template_slug", + post.template_slug, + DocumentFields.get(fields, "templateSlug") + ), diff_field("created_at", post.created_at, DocumentFields.get(fields, "createdAt")), diff_field("updated_at", post.updated_at, DocumentFields.get(fields, "updatedAt")), - diff_field("published_at", post.published_at, DocumentFields.get(fields, "publishedAt")), + diff_field( + "published_at", + post.published_at, + DocumentFields.get(fields, "publishedAt") + ), diff_field("tags", post.tags, Map.get(fields, "tags", [])), diff_field("categories", post.categories, Map.get(fields, "categories", [])) ] @@ -265,7 +273,11 @@ defmodule BDS.Maintenance.DiffReports do diff_field("title", script.title, Map.get(fields, "title")), diff_field("entrypoint", script.entrypoint, Map.get(fields, "entrypoint")), diff_field("enabled", script.enabled, Map.get(fields, "enabled")), - diff_field("created_at", script.created_at, DocumentFields.get(fields, "createdAt")), + diff_field( + "created_at", + script.created_at, + DocumentFields.get(fields, "createdAt") + ), diff_field("updated_at", script.updated_at, DocumentFields.get(fields, "updatedAt")) ] |> Enum.reject(&is_nil/1) @@ -296,8 +308,16 @@ defmodule BDS.Maintenance.DiffReports do [ diff_field("title", template.title, Map.get(fields, "title")), diff_field("enabled", template.enabled, Map.get(fields, "enabled")), - diff_field("created_at", template.created_at, DocumentFields.get(fields, "createdAt")), - diff_field("updated_at", template.updated_at, DocumentFields.get(fields, "updatedAt")) + diff_field( + "created_at", + template.created_at, + DocumentFields.get(fields, "createdAt") + ), + diff_field( + "updated_at", + template.updated_at, + DocumentFields.get(fields, "updatedAt") + ) ] |> Enum.reject(&is_nil/1) diff --git a/lib/bds/mcp/agent_config.ex b/lib/bds/mcp/agent_config.ex index 299015c..9449ec8 100644 --- a/lib/bds/mcp/agent_config.ex +++ b/lib/bds/mcp/agent_config.ex @@ -30,7 +30,9 @@ defmodule BDS.MCP.AgentConfig do end def config_path(:claude_code, home_dir), do: Path.join(home_dir, ".claude.json") - def config_path(:github_copilot, home_dir), do: Path.join([home_dir, "Library", "Application Support", "Code", "User", "mcp.json"]) + + def config_path(:github_copilot, home_dir), + do: Path.join([home_dir, "Library", "Application Support", "Code", "User", "mcp.json"]) def packaged_executable_path(install_root, platform) when is_binary(install_root) do executable_name = @@ -90,12 +92,21 @@ defmodule BDS.MCP.AgentConfig do defp merge_config(:github_copilot, config, command, args) do servers = Map.get(config, "servers", %{}) - Map.put(config, "servers", Map.put(servers, @server_name, %{"type" => "stdio", "command" => command, "args" => args})) + Map.put( + config, + "servers", + Map.put(servers, @server_name, %{"type" => "stdio", "command" => command, "args" => args}) + ) end defp merge_config(:claude_code, config, command, args) do servers = Map.get(config, "mcpServers", %{}) - Map.put(config, "mcpServers", Map.put(servers, @server_name, %{"command" => command, "args" => args})) + + Map.put( + config, + "mcpServers", + Map.put(servers, @server_name, %{"command" => command, "args" => args}) + ) end defp remove_server_entry(:github_copilot, config) do diff --git a/lib/bds/mcp/proposal.ex b/lib/bds/mcp/proposal.ex index c4217c5..13f1a27 100644 --- a/lib/bds/mcp/proposal.ex +++ b/lib/bds/mcp/proposal.ex @@ -8,7 +8,11 @@ defmodule BDS.MCP.Proposal do schema "mcp_proposals" do field :kind, :string - field :status, Ecto.Enum, values: [:pending, :accepted, :discarded, :expired], default: :pending + + field :status, Ecto.Enum, + values: [:pending, :accepted, :discarded, :expired], + default: :pending + field :entity_id, :string field :data, :map field :created_at, :integer @@ -17,7 +21,9 @@ defmodule BDS.MCP.Proposal do def changeset(proposal, attrs) do proposal - |> cast(attrs, [:id, :kind, :status, :entity_id, :data, :created_at, :expires_at], empty_values: [nil]) + |> cast(attrs, [:id, :kind, :status, :entity_id, :data, :created_at, :expires_at], + empty_values: [nil] + ) |> validate_required([:id, :kind, :status, :entity_id, :data, :created_at, :expires_at]) |> unique_constraint(:status, name: :mcp_proposals_entity_idx) end diff --git a/lib/bds/mcp/proposal_store.ex b/lib/bds/mcp/proposal_store.ex index 34a1fa5..b5d7fd4 100644 --- a/lib/bds/mcp/proposal_store.ex +++ b/lib/bds/mcp/proposal_store.ex @@ -74,12 +74,15 @@ defmodule BDS.MCP.ProposalStore do defp mark_status(id, status) do case Repo.get(Proposal, id) do - nil -> nil + nil -> + nil + proposal -> Repo.delete_all( from other in Proposal, where: - other.id != ^id and other.kind == ^proposal.kind and other.entity_id == ^proposal.entity_id and + other.id != ^id and other.kind == ^proposal.kind and + other.entity_id == ^proposal.entity_id and other.status == ^status ) @@ -90,6 +93,7 @@ defmodule BDS.MCP.ProposalStore do end defp derive_entity_id(data) do - data["post_id"] || data["script_id"] || data["template_id"] || data["media_id"] || Ecto.UUID.generate() + data["post_id"] || data["script_id"] || data["template_id"] || data["media_id"] || + Ecto.UUID.generate() end end diff --git a/lib/bds/mcp/server.ex b/lib/bds/mcp/server.ex index ec89c83..33a976a 100644 --- a/lib/bds/mcp/server.ex +++ b/lib/bds/mcp/server.ex @@ -138,8 +138,11 @@ defmodule BDS.MCP.Server do case URI.parse(target) do %URI{path: "/mcp"} -> case GenServer.call(__MODULE__, {:http_request, request}, 5_000) do - {:ok, status, body} -> http_response(status, Jason.encode!(body), "application/json", request.headers) - {:error, status, body} -> http_response(status, body, "text/plain", request.headers) + {:ok, status, body} -> + http_response(status, Jason.encode!(body), "application/json", request.headers) + + {:error, status, body} -> + http_response(status, body, "text/plain", request.headers) end _other -> @@ -170,7 +173,10 @@ defmodule BDS.MCP.Server do success_response(id, %{ "protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"), "capabilities" => %{"tools" => %{}, "resources" => %{}}, - "serverInfo" => %{"name" => @server_name, "version" => Application.spec(:bds, :vsn) |> to_string()} + "serverInfo" => %{ + "name" => @server_name, + "version" => Application.spec(:bds, :vsn) |> to_string() + } })} "tools/list" -> @@ -196,10 +202,17 @@ defmodule BDS.MCP.Server do arguments = Map.get(params, "arguments", %{}) case BDS.MCP.call_tool(name, arguments) do - {:ok, result} -> {:ok, success_response(id, %{"content" => [%{"type" => "json", "json" => result}]})} - {:error, :unknown_tool} -> {:error, error_response(id, -32601, "Unknown tool")} - {:error, :not_found} -> {:error, error_response(id, -32004, "Not found")} - {:error, reason} -> {:error, error_response(id, -32000, inspect(reason))} + {:ok, result} -> + {:ok, success_response(id, %{"content" => [%{"type" => "json", "json" => result}]})} + + {:error, :unknown_tool} -> + {:error, error_response(id, -32601, "Unknown tool")} + + {:error, :not_found} -> + {:error, error_response(id, -32004, "Not found")} + + {:error, reason} -> + {:error, error_response(id, -32000, inspect(reason))} end end @@ -286,7 +299,8 @@ defmodule BDS.MCP.Server do |> IO.iodata_to_binary() end - defp http_error_response(status, headers \\ %{}), do: http_response(status, reason_body(status), "text/plain", headers) + defp http_error_response(status, headers \\ %{}), + do: http_response(status, reason_body(status), "text/plain", headers) defp reason_body(400), do: "Bad Request" defp reason_body(404), do: "Not Found" diff --git a/lib/bds/mcp/stdio.ex b/lib/bds/mcp/stdio.ex index 5a385d3..91434b8 100644 --- a/lib/bds/mcp/stdio.ex +++ b/lib/bds/mcp/stdio.ex @@ -9,8 +9,15 @@ defmodule BDS.MCP.Stdio do if line != "" do response = case Jason.decode(line) do - {:ok, payload} -> handle_payload(payload) - {:error, _reason} -> %{"jsonrpc" => "2.0", "id" => nil, "error" => %{"code" => -32700, "message" => "Parse error"}} + {:ok, payload} -> + handle_payload(payload) + + {:error, _reason} -> + %{ + "jsonrpc" => "2.0", + "id" => nil, + "error" => %{"code" => -32700, "message" => "Parse error"} + } end IO.write(Jason.encode!(response) <> "\n") @@ -18,14 +25,22 @@ defmodule BDS.MCP.Stdio do end) end - defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "initialize", "params" => params}) do + defp handle_payload(%{ + "jsonrpc" => "2.0", + "id" => id, + "method" => "initialize", + "params" => params + }) do %{ "jsonrpc" => "2.0", "id" => id, "result" => %{ "protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"), "capabilities" => %{"tools" => %{}, "resources" => %{}}, - "serverInfo" => %{"name" => "Blogging Desktop Server", "version" => Application.spec(:bds, :vsn) |> to_string()} + "serverInfo" => %{ + "name" => "Blogging Desktop Server", + "version" => Application.spec(:bds, :vsn) |> to_string() + } } } end @@ -34,10 +49,26 @@ defmodule BDS.MCP.Stdio do %{"jsonrpc" => "2.0", "id" => id, "result" => %{"tools" => BDS.MCP.list_tools()}} end - defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "tools/call", "params" => %{"name" => name} = params}) do + defp handle_payload(%{ + "jsonrpc" => "2.0", + "id" => id, + "method" => "tools/call", + "params" => %{"name" => name} = params + }) do case BDS.MCP.call_tool(name, Map.get(params, "arguments", %{})) do - {:ok, result} -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"content" => [%{"type" => "json", "json" => result}]}} - {:error, reason} -> %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32000, "message" => inspect(reason)}} + {:ok, result} -> + %{ + "jsonrpc" => "2.0", + "id" => id, + "result" => %{"content" => [%{"type" => "json", "json" => result}]} + } + + {:error, reason} -> + %{ + "jsonrpc" => "2.0", + "id" => id, + "error" => %{"code" => -32000, "message" => inspect(reason)} + } end end @@ -45,17 +76,38 @@ defmodule BDS.MCP.Stdio do %{"jsonrpc" => "2.0", "id" => id, "result" => %{"resources" => BDS.MCP.list_resources()}} end - defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "resources/read", "params" => %{"uri" => uri}}) do + defp handle_payload(%{ + "jsonrpc" => "2.0", + "id" => id, + "method" => "resources/read", + "params" => %{"uri" => uri} + }) do case BDS.MCP.read_resource(uri) do {:ok, result} -> - %{"jsonrpc" => "2.0", "id" => id, "result" => %{"contents" => [%{"uri" => uri, "mimeType" => "application/json", "text" => Jason.encode!(result)}]}} + %{ + "jsonrpc" => "2.0", + "id" => id, + "result" => %{ + "contents" => [ + %{"uri" => uri, "mimeType" => "application/json", "text" => Jason.encode!(result)} + ] + } + } {:error, reason} -> - %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32000, "message" => inspect(reason)}} + %{ + "jsonrpc" => "2.0", + "id" => id, + "error" => %{"code" => -32000, "message" => inspect(reason)} + } end end defp handle_payload(%{"jsonrpc" => "2.0", "id" => id}) do - %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32601, "message" => "Method not found"}} + %{ + "jsonrpc" => "2.0", + "id" => id, + "error" => %{"code" => -32601, "message" => "Method not found"} + } end end diff --git a/lib/bds/mcp/tools.ex b/lib/bds/mcp/tools.ex index aebbf7b..8cb06c7 100644 --- a/lib/bds/mcp/tools.ex +++ b/lib/bds/mcp/tools.ex @@ -60,8 +60,11 @@ defmodule BDS.MCP.Tools do @spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}} def validate_template(source) when is_binary(source) do case Liquex.parse(source) do - {:ok, _ast} -> {:ok, %{valid: true, errors: []}} - {:error, reason, line} -> {:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}} + {:ok, _ast} -> + {:ok, %{valid: true, errors: []}} + + {:error, reason, line} -> + {:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}} end end @@ -276,7 +279,8 @@ defmodule BDS.MCP.Tools do ttl_ms: @proposal_ttl_app_ms ) - {:ok, %{"proposal_id" => proposal.id, "current" => sanitize(media), "proposed" => changes}} + {:ok, + %{"proposal_id" => proposal.id, "current" => sanitize(media), "proposed" => changes}} end end diff --git a/lib/bds/media/rebuilder.ex b/lib/bds/media/rebuilder.ex index c7a3346..ae2fa41 100644 --- a/lib/bds/media/rebuilder.ex +++ b/lib/bds/media/rebuilder.ex @@ -19,7 +19,8 @@ defmodule BDS.Media.Rebuilder do @type rebuild_opts :: keyword() - @spec rebuild_media_from_files(String.t(), rebuild_opts()) :: {:ok, [Media.t()]} | {:error, term()} + @spec rebuild_media_from_files(String.t(), rebuild_opts()) :: + {:ok, [Media.t()]} | {:error, term()} def rebuild_media_from_files(project_id, opts \\ []) do project = Projects.get_project!(project_id) on_progress = progress_callback(opts) @@ -61,9 +62,10 @@ defmodule BDS.Media.Rebuilder do translation_sidecars |> Enum.with_index(length(canonical_sidecars) + 1) |> Enum.each(fn {sidecar, index} -> - Sidecars.upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar, - sync_search: false - ) + Sidecars.upsert_translation_from_sidecar( + project, + canonical_media_by_binary_path, + sidecar, sync_search: false) :ok = report_rebuild_progress(on_progress, index, total_files, "media files") end) diff --git a/lib/bds/media/sidecars.ex b/lib/bds/media/sidecars.ex index dc2263f..27942aa 100644 --- a/lib/bds/media/sidecars.ex +++ b/lib/bds/media/sidecars.ex @@ -141,7 +141,12 @@ defmodule BDS.Media.Sidecars do media end - @spec upsert_translation_from_sidecar(BDS.Projects.Project.t(), %{required(Path.t()) => Media.t()}, map(), keyword()) :: + @spec upsert_translation_from_sidecar( + BDS.Projects.Project.t(), + %{required(Path.t()) => Media.t()}, + map(), + keyword() + ) :: Translation.t() | :skip | :ok def upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar, opts) do case Map.get(canonical_media_by_binary_path, sidecar.binary_path) do diff --git a/lib/bds/media/thumbnails.ex b/lib/bds/media/thumbnails.ex index 89e828d..924bb1a 100644 --- a/lib/bds/media/thumbnails.ex +++ b/lib/bds/media/thumbnails.ex @@ -70,7 +70,9 @@ defmodule BDS.Media.Thumbnails do missing_paths = media |> thumbnail_paths() - |> Enum.map(fn {_size, relative_path} -> Path.join(Projects.project_data_dir(project), relative_path) end) + |> Enum.map(fn {_size, relative_path} -> + Path.join(Projects.project_data_dir(project), relative_path) + end) |> Enum.reject(&File.exists?/1) next_acc = diff --git a/lib/bds/persistence.ex b/lib/bds/persistence.ex index a6c877b..8259773 100644 --- a/lib/bds/persistence.ex +++ b/lib/bds/persistence.ex @@ -17,7 +17,9 @@ defmodule BDS.Persistence do value |> String.trim() |> case do - "" -> nil + "" -> + nil + trimmed -> case Integer.parse(trimmed) do {integer, ""} -> normalize_unix_timestamp(integer) diff --git a/lib/bds/post_links.ex b/lib/bds/post_links.ex index 1892ca7..9602b73 100644 --- a/lib/bds/post_links.ex +++ b/lib/bds/post_links.ex @@ -9,6 +9,7 @@ defmodule BDS.PostLinks do alias BDS.Projects alias BDS.Repo + @spec sync_post_links(Post.t()) :: :ok def sync_post_links(%Post{} = post) do links = post @@ -41,6 +42,7 @@ defmodule BDS.PostLinks do :ok end + @spec delete_post_links(String.t()) :: :ok def delete_post_links(post_id) when is_binary(post_id) do Repo.delete_all( from link in Link, @@ -50,12 +52,18 @@ defmodule BDS.PostLinks do :ok end + @spec list_outgoing_links(String.t()) :: [Link.t()] def list_outgoing_links(post_id) when is_binary(post_id) do - Repo.all(from link in Link, where: link.source_post_id == ^post_id, order_by: [asc: link.created_at]) + Repo.all( + from link in Link, where: link.source_post_id == ^post_id, order_by: [asc: link.created_at] + ) end + @spec list_incoming_links(String.t()) :: [Link.t()] def list_incoming_links(post_id) when is_binary(post_id) do - Repo.all(from link in Link, where: link.target_post_id == ^post_id, order_by: [asc: link.created_at]) + Repo.all( + from link in Link, where: link.target_post_id == ^post_id, order_by: [asc: link.created_at] + ) end defp post_body(%Post{content: content}) when is_binary(content), do: content @@ -83,11 +91,15 @@ defmodule BDS.PostLinks do defp extract_links(body) when is_binary(body) do markdown_links = Regex.scan(~r/\[([^\]]+)\]\(([^)]+)\)/, body) - |> Enum.map(fn [_full, link_text, href] -> %{link_text: normalize_link_text(link_text), href: href} end) + |> Enum.map(fn [_full, link_text, href] -> + %{link_text: normalize_link_text(link_text), href: href} + end) html_links = Regex.scan(~r/]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/is, body) - |> Enum.map(fn [_full, href, link_text] -> %{link_text: normalize_link_text(link_text), href: href} end) + |> Enum.map(fn [_full, href, link_text] -> + %{link_text: normalize_link_text(link_text), href: href} + end) markdown_links ++ html_links end @@ -121,12 +133,17 @@ defmodule BDS.PostLinks do [language, year, month, day, slug] -> if language_code?(language) and numeric_year?(year) and numeric_month_or_day?(month) and numeric_month_or_day?(day), - do: slug, - else: nil + do: slug, + else: nil - [slug] -> slug - [language, slug] -> if(language_code?(language), do: slug, else: nil) - _other -> nil + [slug] -> + slug + + [language, slug] -> + if(language_code?(language), do: slug, else: nil) + + _other -> + nil end end diff --git a/lib/bds/posts/auto_translation.ex b/lib/bds/posts/auto_translation.ex index 8fbd1d5..2d7be79 100644 --- a/lib/bds/posts/auto_translation.ex +++ b/lib/bds/posts/auto_translation.ex @@ -122,7 +122,8 @@ defmodule BDS.Posts.AutoTranslation do defp media_needed?(media_id, language) do case Repo.get(Media.Media, media_id) do - %Media.Media{language: source_language} when source_language not in [nil, ""] and source_language != language -> + %Media.Media{language: source_language} + when source_language not in [nil, ""] and source_language != language -> not Repo.exists?( from translation in Media.Translation, where: translation.translation_for == ^media_id and translation.language == ^language diff --git a/lib/bds/posts/link.ex b/lib/bds/posts/link.ex index 956d030..0d868bf 100644 --- a/lib/bds/posts/link.ex +++ b/lib/bds/posts/link.ex @@ -18,8 +18,15 @@ defmodule BDS.Posts.Link do } schema "post_links" do - belongs_to :source_post, BDS.Posts.Post, foreign_key: :source_post_id, references: :id, type: :string - belongs_to :target_post, BDS.Posts.Post, foreign_key: :target_post_id, references: :id, type: :string + belongs_to :source_post, BDS.Posts.Post, + foreign_key: :source_post_id, + references: :id, + type: :string + + belongs_to :target_post, BDS.Posts.Post, + foreign_key: :target_post_id, + references: :id, + type: :string field :link_text, :string field :created_at, :integer diff --git a/lib/bds/posts/translation_validation.ex b/lib/bds/posts/translation_validation.ex index 921056f..c1a7940 100644 --- a/lib/bds/posts/translation_validation.ex +++ b/lib/bds/posts/translation_validation.ex @@ -50,7 +50,11 @@ defmodule BDS.Posts.TranslationValidation do Repo.all( from translation in Translation, where: translation.project_id == ^project_id, - order_by: [asc: translation.translation_for, asc: translation.language, asc: translation.id] + order_by: [ + asc: translation.translation_for, + asc: translation.language, + asc: translation.id + ] ) project_data_dir = Projects.project_data_dir(project) @@ -67,7 +71,13 @@ defmodule BDS.Posts.TranslationValidation do translation_rows |> Enum.with_index(1) |> Enum.flat_map(fn {translation, index} -> - :ok = RebuildFromFiles.report_rebuild_progress(on_progress, index, total_items, "translations") + :ok = + RebuildFromFiles.report_rebuild_progress( + on_progress, + index, + total_items, + "translations" + ) case invalid_database_translation_issue(translation, source_post_map, metadata) do nil -> [] @@ -80,7 +90,13 @@ defmodule BDS.Posts.TranslationValidation do markdown_files |> Enum.with_index(length(translation_rows) + 1) |> Enum.reduce({0, []}, fn {file_path, index}, {count, issues} -> - :ok = RebuildFromFiles.report_rebuild_progress(on_progress, index, total_items, "translations") + :ok = + RebuildFromFiles.report_rebuild_progress( + on_progress, + index, + total_items, + "translations" + ) case invalid_filesystem_translation_issue(file_path, source_post_map, metadata) do {:ok, nil} -> {count + 1, issues} @@ -118,11 +134,19 @@ defmodule BDS.Posts.TranslationValidation do normalized_report = normalize_report(report) {deleted_database_rows, flushed_translations, synced_post_ids} = - Enum.reduce(normalized_report.invalid_database_rows, {0, 0, MapSet.new()}, fn issue, {deleted, flushed, synced_ids} -> + Enum.reduce(normalized_report.invalid_database_rows, {0, 0, MapSet.new()}, fn issue, + {deleted, + flushed, + synced_ids} -> case fix_invalid_database_row(issue) do - {:deleted, post_id} -> {deleted + 1, flushed, maybe_put_synced_post(synced_ids, post_id)} - {:flushed, post_id} -> {deleted, flushed + 1, maybe_put_synced_post(synced_ids, post_id)} - :noop -> {deleted, flushed, synced_ids} + {:deleted, post_id} -> + {deleted + 1, flushed, maybe_put_synced_post(synced_ids, post_id)} + + {:flushed, post_id} -> + {deleted, flushed + 1, maybe_put_synced_post(synced_ids, post_id)} + + :noop -> + {deleted, flushed, synced_ids} end end) @@ -365,7 +389,10 @@ defmodule BDS.Posts.TranslationValidation do end end - defp fix_invalid_database_row(%{translation_id: translation_id, translation_for: translation_for}) + defp fix_invalid_database_row(%{ + translation_id: translation_id, + translation_for: translation_for + }) when is_binary(translation_id) do case Repo.get(Translation, translation_id) do %Translation{} = translation -> @@ -402,7 +429,11 @@ defmodule BDS.Posts.TranslationValidation do end defp issue_sort_key(issue) do - [Map.get(issue, :translation_for), Map.get(issue, :translation_id), Map.get(issue, :file_path)] + [ + Map.get(issue, :translation_for), + Map.get(issue, :translation_id), + Map.get(issue, :file_path) + ] |> Enum.map(&to_string(&1 || "")) |> Enum.join(":") end diff --git a/lib/bds/preview.ex b/lib/bds/preview.ex index 3d3045f..fd8a585 100644 --- a/lib/bds/preview.ex +++ b/lib/bds/preview.ex @@ -64,7 +64,11 @@ defmodule BDS.Preview do {:reply, reply, next_state} end - def handle_call({:ensure_preview, project_id, _data_dir, _owner_pid}, _from, %{current: %{project_id: project_id, is_running: true}} = state) do + def handle_call( + {:ensure_preview, project_id, _data_dir, _owner_pid}, + _from, + %{current: %{project_id: project_id, is_running: true}} = state + ) do {:reply, {:ok, public_server(state.current)}, state} end @@ -224,7 +228,9 @@ defmodule BDS.Preview do end defp draft_preview_translation(_post_id, nil, _post_language), do: nil - defp draft_preview_translation(_post_id, requested_language, post_language) when requested_language == post_language, do: nil + + defp draft_preview_translation(_post_id, requested_language, post_language) + when requested_language == post_language, do: nil defp draft_preview_translation(post_id, requested_language, _post_language) do Repo.get_by(Translation, translation_for: post_id, language: requested_language) @@ -456,7 +462,10 @@ defmodule BDS.Preview do {uri.path || "/", URI.decode_query(uri.query || "")} end - defp apply_response_overrides(%{content_type: content_type, body: body} = response, query_params) + defp apply_response_overrides( + %{content_type: content_type, body: body} = response, + query_params + ) when is_binary(content_type) and is_binary(body) do if String.starts_with?(content_type, "text/html") do %{response | body: apply_preview_overrides(body, query_params)} @@ -465,7 +474,8 @@ defmodule BDS.Preview do end end - defp apply_preview_overrides(body, query_params) when is_binary(body) and is_map(query_params) do + defp apply_preview_overrides(body, query_params) + when is_binary(body) and is_map(query_params) do theme_override = normalize_pico_theme_override(query_params["theme"]) mode_override = normalize_mode_override(query_params["mode"]) @@ -506,7 +516,9 @@ defmodule BDS.Preview do [html_tag] -> replacement = if String.contains?(html_tag, attribute <> "=") do - Regex.replace(~r/\s#{attribute}="[^"]*"/, html_tag, ~s( #{attribute}="#{value}"), global: false) + Regex.replace(~r/\s#{attribute}="[^"]*"/, html_tag, ~s( #{attribute}="#{value}"), + global: false + ) else String.replace_suffix(html_tag, ">", ~s( #{attribute}="#{value}">)) end @@ -520,7 +532,11 @@ defmodule BDS.Preview do defp not_found_assigns(query_params) do %{} - |> maybe_put_assign("pico_stylesheet_href", normalize_pico_theme_override(query_params["theme"]), &PreviewAssets.stylesheet_href/1) + |> maybe_put_assign( + "pico_stylesheet_href", + normalize_pico_theme_override(query_params["theme"]), + &PreviewAssets.stylesheet_href/1 + ) end defp maybe_put_assign(assigns, _key, nil, _mapper), do: assigns diff --git a/lib/bds/projects.ex b/lib/bds/projects.ex index 5c4e5a1..24c47f4 100644 --- a/lib/bds/projects.ex +++ b/lib/bds/projects.ex @@ -134,7 +134,8 @@ defmodule BDS.Projects do sync_filesystem_metadata(project) end - {:error, reason} -> {:error, reason} + {:error, reason} -> + {:error, reason} end end @@ -166,7 +167,8 @@ defmodule BDS.Projects do @spec delete_project(String.t()) :: {:ok, Project.t()} - | {:error, :not_found | :cannot_delete_default_project | :cannot_delete_active_project | term()} + | {:error, + :not_found | :cannot_delete_default_project | :cannot_delete_active_project | term()} def delete_project(project_id) when is_binary(project_id) do case Repo.get(Project, project_id) do nil -> @@ -180,7 +182,9 @@ defmodule BDS.Projects do %Project{} = project -> internal_dir = if is_nil(project.data_path), do: project_data_dir(project), else: nil - cleanup_dirs = [internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq() + + cleanup_dirs = + [internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq() Repo.transaction(fn -> Repo.delete!(project) diff --git a/lib/bds/rebuild.ex b/lib/bds/rebuild.ex index 736af49..462691e 100644 --- a/lib/bds/rebuild.ex +++ b/lib/bds/rebuild.ex @@ -7,7 +7,11 @@ defmodule BDS.Rebuild do timeout = Keyword.get(opts, :timeout, :infinity) items - |> Task.async_stream(mapper, max_concurrency: max_concurrency, ordered: ordered, timeout: timeout) + |> Task.async_stream(mapper, + max_concurrency: max_concurrency, + ordered: ordered, + timeout: timeout + ) |> Enum.map(fn {:ok, item} -> item {:exit, reason} -> exit(reason) diff --git a/lib/bds/release_packaging.ex b/lib/bds/release_packaging.ex index 67261ad..273f9ef 100644 --- a/lib/bds/release_packaging.ex +++ b/lib/bds/release_packaging.ex @@ -26,7 +26,8 @@ defmodule BDS.ReleasePackaging do ] end - def build_metadata(platform, version, output_dir) when is_binary(version) and is_binary(output_dir) do + def build_metadata(platform, version, output_dir) + when is_binary(version) and is_binary(output_dir) do normalized_platform = normalize_platform(platform) payload_name = "bds2-#{normalized_platform}-#{version}" payload_root = Path.join(output_dir, payload_name) @@ -66,7 +67,9 @@ defmodule BDS.ReleasePackaging do defp normalize_platform(platform) when platform in [:macos, :linux, :windows], do: platform defp normalize_platform(:darwin), do: :macos - defp normalize_platform(platform) when is_binary(platform), do: platform |> String.downcase() |> String.to_atom() + + defp normalize_platform(platform) when is_binary(platform), + do: platform |> String.downcase() |> String.to_atom() defp archive_extension(:windows), do: ".zip" defp archive_extension(_platform), do: ".tar.gz" @@ -107,7 +110,9 @@ defmodule BDS.ReleasePackaging do relative_entries = collect_entries(metadata.payload_root) cwd = metadata.output_dir |> String.to_charlist() archive = metadata.archive_path |> String.to_charlist() - entries = Enum.map(relative_entries, &String.to_charlist(Path.join(metadata.payload_name, &1))) + + entries = + Enum.map(relative_entries, &String.to_charlist(Path.join(metadata.payload_name, &1))) case :zip.create(archive, entries, cwd: cwd) do {:ok, _archive_path} -> :ok @@ -116,7 +121,13 @@ defmodule BDS.ReleasePackaging do end defp create_archive(metadata) do - case System.cmd("tar", ["-czf", metadata.archive_path, "-C", metadata.output_dir, metadata.payload_name]) do + case System.cmd("tar", [ + "-czf", + metadata.archive_path, + "-C", + metadata.output_dir, + metadata.payload_name + ]) do {_output, 0} -> :ok {output, status} -> {:error, {:tar_failed, status, output}} end diff --git a/lib/bds/rendering.ex b/lib/bds/rendering.ex index b84a714..40464d1 100644 --- a/lib/bds/rendering.ex +++ b/lib/bds/rendering.ex @@ -7,9 +7,14 @@ defmodule BDS.Rendering do def render_post_page(project_id, template_slug, assigns) when is_binary(project_id) and is_map(assigns) do - with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :post, template_slug), + with {:ok, template_source} <- + TemplateSelection.load_template_source(project_id, :post, template_slug), {:ok, rendered} <- - TemplateSelection.render_template(project_id, template_source, PostRendering.post_assigns(project_id, assigns)) do + TemplateSelection.render_template( + project_id, + template_source, + PostRendering.post_assigns(project_id, assigns) + ) do {:ok, rendered} end end @@ -17,16 +22,25 @@ defmodule BDS.Rendering do def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :list, nil), {:ok, rendered} <- - TemplateSelection.render_template(project_id, template_source, ListArchive.list_assigns(project_id, assigns)) do + TemplateSelection.render_template( + project_id, + template_source, + ListArchive.list_assigns(project_id, assigns) + ) do {:ok, rendered} end end def render_not_found_page(project_id, assigns \\ %{}) when is_binary(project_id) and is_map(assigns) do - with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :not_found, nil), + with {:ok, template_source} <- + TemplateSelection.load_template_source(project_id, :not_found, nil), {:ok, rendered} <- - TemplateSelection.render_template(project_id, template_source, ListArchive.not_found_assigns(project_id, assigns)) do + TemplateSelection.render_template( + project_id, + template_source, + ListArchive.not_found_assigns(project_id, assigns) + ) do {:ok, rendered} end end diff --git a/lib/bds/rendering/metadata.ex b/lib/bds/rendering/metadata.ex index 4b3156f..bbe1f4f 100644 --- a/lib/bds/rendering/metadata.ex +++ b/lib/bds/rendering/metadata.ex @@ -54,7 +54,9 @@ defmodule BDS.Rendering.Metadata do |> Enum.uniq() |> Enum.map(fn language -> normalized = I18n.normalize_language(language) - href_prefix = LinksAndLanguages.language_prefix(normalized, metadata.main_language || current_language) + + href_prefix = + LinksAndLanguages.language_prefix(normalized, metadata.main_language || current_language) %{ code: normalized, @@ -84,9 +86,17 @@ defmodule BDS.Rendering.Metadata do order_by: [asc: translation.language] ) - [%{href: LinksAndLanguages.post_path(post, nil), hreflang: LinksAndLanguages.normalize_language(post.language, main_language)}] ++ + [ + %{ + href: LinksAndLanguages.post_path(post, nil), + hreflang: LinksAndLanguages.normalize_language(post.language, main_language) + } + ] ++ Enum.map(translations, fn translation -> - %{href: LinksAndLanguages.post_path(post, translation.language, main_language), hreflang: translation.language} + %{ + href: LinksAndLanguages.post_path(post, translation.language, main_language), + hreflang: translation.language + } end) end diff --git a/lib/bds/scripting.ex b/lib/bds/scripting.ex index 2d91401..0f5334a 100644 --- a/lib/bds/scripting.ex +++ b/lib/bds/scripting.ex @@ -36,7 +36,9 @@ defmodule BDS.Scripting do runtime().execute(source, entrypoint, args, opts) end - @spec execute_project_script(String.t(), String.t(), String.t(), [term()], [Runtime.execution_option()]) :: + @spec execute_project_script(String.t(), String.t(), String.t(), [term()], [ + Runtime.execution_option() + ]) :: {:ok, term()} | {:error, term()} def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ []) when is_binary(project_id) and is_binary(source) and is_binary(entrypoint) and @@ -45,13 +47,20 @@ defmodule BDS.Scripting do execute(source, entrypoint, args, Keyword.put(opts, :capabilities, capabilities)) end - @spec execute_macro(String.t(), String.t(), [term()], keyword()) :: {:ok, String.t()} | {:error, term()} + @spec execute_macro(String.t(), String.t(), [term()], keyword()) :: + {:ok, String.t()} | {:error, term()} def execute_macro(project_id, source, args, opts \\ []) when is_binary(project_id) and is_binary(source) and is_list(args) and is_list(opts) do config = Application.fetch_env!(:bds, :scripting) timeout = Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout)) - case execute_project_script(project_id, source, "render", args, Keyword.put(opts, :timeout, timeout)) do + case execute_project_script( + project_id, + source, + "render", + args, + Keyword.put(opts, :timeout, timeout) + ) do {:ok, nil} -> {:ok, ""} {:ok, value} -> {:ok, to_string(value)} {:error, _reason} -> {:ok, ""} diff --git a/lib/bds/scripting/api_docs.ex b/lib/bds/scripting/api_docs.ex index c8a07fb..505d2a5 100644 --- a/lib/bds/scripting/api_docs.ex +++ b/lib/bds/scripting/api_docs.ex @@ -4,160 +4,1204 @@ defmodule BDS.Scripting.ApiDocs do @version "0.4.0" @methods [ - %{module: "app", name: "copy_to_clipboard", description: "Copy text to the system clipboard.", params: [%{name: "text", type: "string", required: true}], returns: "boolean"}, - %{module: "app", name: "get_blogmark_bookmarklet", description: "Return the Blogmark bookmarklet JavaScript source.", params: [], returns: "string"}, - %{module: "app", name: "get_data_paths", description: "Return filesystem paths for the current application and project data.", params: [], returns: "table"}, - %{module: "app", name: "get_default_project_path", description: "Return the current project's filesystem path.", params: [], returns: "string | nil"}, - %{module: "app", name: "get_system_language", description: "Return the current UI locale.", params: [], returns: "string | nil"}, - %{module: "app", name: "get_title_bar_metrics", description: "Return desktop title bar inset metrics when available.", params: [], returns: "table | nil"}, - %{module: "app", name: "notify_renderer_ready", description: "Notify the host application that the renderer is ready.", params: [], returns: "boolean"}, - %{module: "app", name: "open_folder", description: "Open a folder in the system file manager.", params: [%{name: "folder_path", type: "string", required: true}], returns: "string"}, - %{module: "app", name: "read_project_metadata", description: "Read project metadata from a project folder path.", params: [%{name: "folder_path", type: "string", required: true}], returns: "ProjectMetadata | nil"}, - %{module: "app", name: "select_folder", description: "Show the native folder picker and return the chosen path.", params: [%{name: "title", type: "string", required: false}], returns: "string | nil"}, - %{module: "app", name: "set_preview_post_target", description: "Set the current preview-post target used by desktop integrations.", params: [%{name: "post_id", type: "string", required: false}], returns: "boolean"}, - %{module: "app", name: "show_item_in_folder", description: "Reveal a file or folder in the system file manager.", params: [%{name: "item_path", type: "string", required: true}], returns: "nil"}, - %{module: "app", name: "trigger_menu_action", description: "Trigger a native menu action by action id.", params: [%{name: "action", type: "string", required: true}], returns: "nil"}, - %{module: "projects", name: "create", description: "Create a project.", params: [%{name: "data", type: "table", required: true}], returns: "ProjectData | nil"}, - %{module: "projects", name: "delete", description: "Delete a project by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"}, - %{module: "projects", name: "delete_with_data", description: "Delete a project by id and remove its project directory.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"}, - %{module: "projects", name: "get", description: "Fetch one project by id.", params: [%{name: "id", type: "string", required: true}], returns: "ProjectData | nil"}, - %{module: "projects", name: "get_all", description: "Fetch all projects.", params: [], returns: "ProjectData[]"}, - %{module: "projects", name: "get_active", description: "Fetch the active project.", params: [], returns: "ProjectData | nil"}, - %{module: "projects", name: "set_active", description: "Set the active project by id.", params: [%{name: "id", type: "string", required: true}], returns: "ProjectData | nil"}, - %{module: "projects", name: "update", description: "Update a project by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "ProjectData | nil"}, - %{module: "posts", name: "create", description: "Create a post in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "PostData | nil"}, - %{module: "posts", name: "update", description: "Update a post by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "PostData | nil"}, - %{module: "posts", name: "delete", description: "Delete a post by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"}, - %{module: "posts", name: "discard", description: "Discard unpublished post changes and restore the last published version from disk.", params: [%{name: "id", type: "string", required: true}], returns: "PostData | nil"}, - %{module: "posts", name: "filter", description: "Filter posts using status, tags, categories, language, year, month, or date range fields.", params: [%{name: "filters", type: "table", required: true}], returns: "PostData[] | nil"}, - %{module: "posts", name: "generate_unique_slug", description: "Generate a unique slug from a title, optionally excluding one post id.", params: [%{name: "title", type: "string", required: true}, %{name: "exclude_post_id", type: "string", required: false}], returns: "string"}, - %{module: "posts", name: "get", description: "Fetch one post by id.", params: [%{name: "id", type: "string", required: true}], returns: "PostData | nil"}, - %{module: "posts", name: "get_all", description: "Fetch all posts in the current project.", params: [], returns: "PostData[]"}, - %{module: "posts", name: "get_by_slug", description: "Fetch one post by slug.", params: [%{name: "slug", type: "string", required: true}], returns: "PostData | nil"}, - %{module: "posts", name: "get_by_status", description: "Fetch posts filtered by a specific status.", params: [%{name: "status", type: "string", required: true}], returns: "PostData[]"}, - %{module: "posts", name: "get_by_year_month", description: "Get post counts grouped by year and month.", params: [], returns: "table[]"}, - %{module: "posts", name: "get_categories", description: "Get category names used by posts in the current project.", params: [], returns: "string[]"}, - %{module: "posts", name: "get_categories_with_counts", description: "Get post categories with usage counts.", params: [], returns: "table[]"}, - %{module: "posts", name: "get_dashboard_stats", description: "Return aggregate post dashboard counts for the current project.", params: [], returns: "table"}, - %{module: "posts", name: "get_linked_by", description: "Return posts that link to the given post.", params: [%{name: "post_id", type: "string", required: true}], returns: "table[]"}, - %{module: "posts", name: "get_links_to", description: "Return posts linked from the given post.", params: [%{name: "post_id", type: "string", required: true}], returns: "table[]"}, - %{module: "posts", name: "get_preview_url", description: "Return the local preview URL for a post, optionally with draft and language query parameters.", params: [%{name: "post_id", type: "string", required: true}, %{name: "options", type: "table", required: false}], returns: "string | nil"}, - %{module: "posts", name: "get_tags", description: "Get tag names used by posts in the current project.", params: [], returns: "string[]"}, - %{module: "posts", name: "get_tags_with_counts", description: "Get post tags with usage counts.", params: [], returns: "table[]"}, - %{module: "posts", name: "get_translation", description: "Get a single translation for a post by language.", params: [%{name: "post_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"}, - %{module: "posts", name: "get_translations", description: "Get all translations for a post.", params: [%{name: "post_id", type: "string", required: true}], returns: "table[]"}, - %{module: "posts", name: "has_published_version", description: "Check whether a post has a published version.", params: [%{name: "post_id", type: "string", required: true}], returns: "boolean"}, - %{module: "posts", name: "is_slug_available", description: "Return whether a slug is available in the current project, optionally excluding one post id.", params: [%{name: "slug", type: "string", required: true}, %{name: "exclude_post_id", type: "string", required: false}], returns: "boolean"}, - %{module: "posts", name: "publish", description: "Publish a post by id.", params: [%{name: "id", type: "string", required: true}], returns: "PostData | nil"}, - %{module: "posts", name: "publish_translation", description: "Publish one translation of a post by language.", params: [%{name: "post_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"}, - %{module: "posts", name: "rebuild_from_files", description: "Rebuild post records from published files.", params: [], returns: "PostData[] | nil"}, - %{module: "posts", name: "rebuild_links", description: "Rebuild the post link graph for the current project.", params: [], returns: "boolean"}, - %{module: "posts", name: "reindex_text", description: "Reindex post and media search text for the current project.", params: [], returns: "boolean"}, - %{module: "posts", name: "search", description: "Search posts by free-text query.", params: [%{name: "query", type: "string", required: true}], returns: "PostData[] | nil"}, - %{module: "media", name: "delete_translation", description: "Delete a media translation by language.", params: [%{name: "media_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "boolean"}, - %{module: "media", name: "filter", description: "Filter media using year, month, tags, language, or date range fields.", params: [%{name: "filters", type: "table", required: true}], returns: "MediaData[]"}, - %{module: "media", name: "import", description: "Import media into the current project.", params: [%{name: "data", type: "table", required: true}], returns: "MediaData | nil"}, - %{module: "media", name: "get_by_year_month", description: "Get media counts grouped by year and month.", params: [], returns: "table[]"}, - %{module: "media", name: "get_file_path", description: "Return the absolute file path for a media item.", params: [%{name: "media_id", type: "string", required: true}], returns: "string | nil"}, - %{module: "media", name: "update", description: "Update media metadata by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "MediaData | nil"}, - %{module: "media", name: "delete", description: "Delete a media item by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"}, - %{module: "media", name: "get", description: "Fetch one media item by id.", params: [%{name: "id", type: "string", required: true}], returns: "MediaData | nil"}, - %{module: "media", name: "get_all", description: "Fetch all media in the current project.", params: [], returns: "MediaData[]"}, - %{module: "media", name: "get_tags", description: "Return tag names used by media in the current project.", params: [], returns: "string[]"}, - %{module: "media", name: "get_tags_with_counts", description: "Return media tags with usage counts.", params: [], returns: "table[]"}, - %{module: "media", name: "get_thumbnail", description: "Return a media thumbnail as a data URL for the requested size.", params: [%{name: "media_id", type: "string", required: true}, %{name: "size", type: "string", required: false}], returns: "string | nil"}, - %{module: "media", name: "get_translation", description: "Return one media translation by language.", params: [%{name: "media_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"}, - %{module: "media", name: "get_translations", description: "Return all translations for a media item.", params: [%{name: "media_id", type: "string", required: true}], returns: "table[]"}, - %{module: "media", name: "get_url", description: "Return the project-relative public URL path for a media item.", params: [%{name: "media_id", type: "string", required: true}], returns: "string | nil"}, - %{module: "media", name: "rebuild_from_files", description: "Rebuild media records from sidecar files on disk.", params: [], returns: "MediaData[] | nil"}, - %{module: "media", name: "regenerate_missing_thumbnails", description: "Generate thumbnails for media items that are missing them.", params: [], returns: "table"}, - %{module: "media", name: "regenerate_thumbnails", description: "Regenerate all thumbnails for one media item.", params: [%{name: "media_id", type: "string", required: true}], returns: "table | nil"}, - %{module: "media", name: "reindex_text", description: "Reindex post and media search text for the current project.", params: [], returns: "boolean"}, - %{module: "media", name: "replace_file", description: "Replace the binary file behind an existing media item.", params: [%{name: "media_id", type: "string", required: true}, %{name: "source_path", type: "string", required: true}], returns: "MediaData | nil"}, - %{module: "media", name: "search", description: "Search media by free-text query.", params: [%{name: "query", type: "string", required: true}], returns: "MediaData[] | nil"}, - %{module: "media", name: "upsert_translation", description: "Create or update a media translation.", params: [%{name: "media_id", type: "string", required: true}, %{name: "language", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "table | nil"}, - %{module: "scripts", name: "create", description: "Create a script in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "ScriptData | nil"}, - %{module: "scripts", name: "update", description: "Update a script by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "ScriptData | nil"}, - %{module: "scripts", name: "delete", description: "Delete a script by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"}, - %{module: "scripts", name: "get", description: "Fetch one script by id.", params: [%{name: "id", type: "string", required: true}], returns: "ScriptData | nil"}, - %{module: "scripts", name: "get_all", description: "Fetch all scripts in the current project.", params: [], returns: "ScriptData[]"}, - %{module: "scripts", name: "publish", description: "Publish a script by id.", params: [%{name: "id", type: "string", required: true}], returns: "ScriptData | nil"}, - %{module: "scripts", name: "rebuild_from_files", description: "Rebuild script records from published files.", params: [], returns: "ScriptData[] | nil"}, - %{module: "templates", name: "create", description: "Create a template in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "TemplateData | nil"}, - %{module: "templates", name: "update", description: "Update a template by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "TemplateData | nil"}, - %{module: "templates", name: "delete", description: "Delete a template by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"}, - %{module: "templates", name: "get", description: "Fetch one template by id.", params: [%{name: "id", type: "string", required: true}], returns: "TemplateData | nil"}, - %{module: "templates", name: "get_all", description: "Fetch all templates in the current project.", params: [], returns: "TemplateData[]"}, - %{module: "templates", name: "get_enabled_by_kind", description: "Fetch enabled templates filtered by kind.", params: [%{name: "kind", type: "string", required: true}], returns: "TemplateData[]"}, - %{module: "templates", name: "publish", description: "Publish a template by id.", params: [%{name: "id", type: "string", required: true}], returns: "TemplateData | nil"}, - %{module: "templates", name: "rebuild_from_files", description: "Rebuild template records from published files.", params: [], returns: "TemplateData[] | nil"}, - %{module: "templates", name: "validate", description: "Validate Liquid template syntax.", params: [%{name: "content", type: "string", required: true}], returns: "ValidationResult | nil"}, - %{module: "meta", name: "add_category", description: "Add a category to the current project.", params: [%{name: "name", type: "string", required: true}], returns: "ProjectMetadata | nil"}, - %{module: "meta", name: "add_tag", description: "Add a tag record to the current project if it does not already exist.", params: [%{name: "name", type: "string", required: true}], returns: "string[]"}, - %{module: "meta", name: "clear_publishing_preferences", description: "Reset publishing preferences to defaults.", params: [], returns: "table | nil"}, - %{module: "meta", name: "get_categories", description: "Get project categories.", params: [], returns: "string[]"}, - %{module: "meta", name: "get_project_metadata", description: "Read metadata for the current project.", params: [], returns: "ProjectMetadata"}, - %{module: "meta", name: "get_publishing_preferences", description: "Get publishing preferences for the current project.", params: [], returns: "table | nil"}, - %{module: "meta", name: "get_tags", description: "Get tag names for the current project.", params: [], returns: "string[]"}, - %{module: "meta", name: "remove_category", description: "Remove a category from the current project.", params: [%{name: "name", type: "string", required: true}], returns: "ProjectMetadata | nil"}, - %{module: "meta", name: "remove_tag", description: "Remove a tag record from the current project by name.", params: [%{name: "name", type: "string", required: true}], returns: "string[]"}, - %{module: "meta", name: "set_project_metadata", description: "Replace project metadata fields for the current project.", params: [%{name: "updates", type: "table", required: true}], returns: "ProjectMetadata | nil"}, - %{module: "meta", name: "set_publishing_preferences", description: "Set publishing preferences for the current project.", params: [%{name: "prefs", type: "table", required: true}], returns: "table | nil"}, - %{module: "meta", name: "sync_on_startup", description: "Synchronize startup metadata state and return tags, categories, and project metadata.", params: [], returns: "table"}, - %{module: "meta", name: "update_project_metadata", description: "Update metadata for the current project.", params: [%{name: "updates", type: "table", required: true}], returns: "ProjectMetadata | nil"}, - %{module: "tags", name: "create", description: "Create a tag in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "TagData | nil"}, - %{module: "tags", name: "update", description: "Update a tag by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "TagData | nil"}, - %{module: "tags", name: "delete", description: "Delete a tag by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"}, - %{module: "tags", name: "get", description: "Fetch one tag by id.", params: [%{name: "id", type: "string", required: true}], returns: "TagData | nil"}, - %{module: "tags", name: "get_all", description: "Fetch all tags in the current project.", params: [], returns: "TagData[]"}, - %{module: "tags", name: "get_by_name", description: "Fetch one tag by name.", params: [%{name: "name", type: "string", required: true}], returns: "TagData | nil"}, - %{module: "tags", name: "get_posts_with_tag", description: "Get post ids using a specific tag.", params: [%{name: "tag_id", type: "string", required: true}], returns: "string[]"}, - %{module: "tags", name: "get_with_counts", description: "Fetch tags with usage counts.", params: [], returns: "table[]"}, - %{module: "tags", name: "merge", description: "Merge source tags into a target tag.", params: [%{name: "source_tag_ids", type: "table", required: true}, %{name: "target_tag_id", type: "string", required: true}], returns: "boolean"}, - %{module: "tags", name: "rename", description: "Rename a tag by id.", params: [%{name: "id", type: "string", required: true}, %{name: "new_name", type: "string", required: true}], returns: "TagData | nil"}, - %{module: "tags", name: "sync_from_posts", description: "Sync tag records from post tags.", params: [], returns: "TagData[] | nil"}, - %{module: "tasks", name: "cancel", description: "Cancel a task by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"}, - %{module: "tasks", name: "clear_completed", description: "Clear completed tasks from the in-memory task list.", params: [], returns: "boolean"}, - %{module: "tasks", name: "get", description: "Fetch one task by id.", params: [%{name: "id", type: "string", required: true}], returns: "TaskData | nil"}, - %{module: "tasks", name: "get_all", description: "Fetch all tasks currently tracked by the task manager.", params: [], returns: "TaskData[]"}, - %{module: "tasks", name: "get_running", description: "Fetch running tasks currently tracked by the task manager.", params: [], returns: "TaskData[]"}, - %{module: "tasks", name: "status_snapshot", description: "Fetch the current task status snapshot.", params: [], returns: "TaskStatus"}, - %{module: "sync", name: "check_availability", description: "Return whether Git is available on the current machine.", params: [], returns: "boolean"}, - %{module: "sync", name: "commit_all", description: "Commit all pending repository changes for the current project.", params: [%{name: "message", type: "string", required: true}], returns: "table | nil"}, - %{module: "sync", name: "fetch", description: "Fetch remote Git refs for the current project.", params: [], returns: "table | nil"}, - %{module: "sync", name: "get_history", description: "Return commit history for the current project repository.", params: [], returns: "table | nil"}, - %{module: "sync", name: "get_remote_state", description: "Return remote repository state information for the current project.", params: [], returns: "table | nil"}, - %{module: "sync", name: "get_repo_state", description: "Return repository state information for the current project.", params: [], returns: "table | nil"}, - %{module: "sync", name: "get_status", description: "Return Git status information for the current project.", params: [], returns: "table | nil"}, - %{module: "sync", name: "pull", description: "Pull remote changes for the current project.", params: [], returns: "table | nil"}, - %{module: "sync", name: "push", description: "Push local changes for the current project.", params: [], returns: "table | nil"}, - %{module: "publish", name: "upload_site", description: "Upload the rendered site using the provided publishing credentials.", params: [%{name: "credentials", type: "table", required: true}], returns: "TaskData | nil"}, - %{module: "chat", name: "analyze_media_image", description: "Analyze a media image using the configured AI runtime.", params: [%{name: "media_id", type: "string", required: true}], returns: "table | nil"}, - %{module: "chat", name: "analyze_post", description: "Analyze a post using the configured AI runtime.", params: [%{name: "post_id", type: "string", required: true}], returns: "table | nil"}, - %{module: "chat", name: "detect_media_language", description: "Detect the language of media metadata.", params: [%{name: "title", type: "string", required: true}, %{name: "alt", type: "string", required: false}, %{name: "caption", type: "string", required: false}], returns: "table"}, - %{module: "chat", name: "detect_post_language", description: "Detect the language of post title and content.", params: [%{name: "title", type: "string", required: true}, %{name: "content", type: "string", required: true}], returns: "table"}, - %{module: "chat", name: "translate_media_metadata", description: "Translate media metadata and persist the translation.", params: [%{name: "media_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"}, - %{module: "chat", name: "translate_post", description: "Translate a post and persist the translation.", params: [%{name: "post_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"}, - %{module: "embeddings", name: "compute_similarities", description: "Compute similarity scores from one source post to target posts.", params: [%{name: "post_id", type: "string", required: true}, %{name: "target_ids", type: "table", required: true}], returns: "table | nil"}, - %{module: "embeddings", name: "dismiss_pair", description: "Dismiss a duplicate candidate pair.", params: [%{name: "post_id_a", type: "string", required: true}, %{name: "post_id_b", type: "string", required: true}], returns: "boolean"}, - %{module: "embeddings", name: "find_duplicates", description: "Find duplicate post candidates for the current project.", params: [], returns: "table | nil"}, - %{module: "embeddings", name: "find_similar", description: "Find posts similar to the given post id.", params: [%{name: "post_id", type: "string", required: true}, %{name: "limit", type: "integer", required: false}], returns: "table | nil"}, - %{module: "embeddings", name: "get_progress", description: "Get embedding index progress for the current project.", params: [], returns: "table | nil"}, - %{module: "embeddings", name: "index_unindexed_posts", description: "Index posts missing embeddings for the current project.", params: [], returns: "table | nil"}, - %{module: "embeddings", name: "suggest_tags", description: "Suggest tags for a post from semantic similarity.", params: [%{name: "post_id", type: "string", required: true}, %{name: "exclude_tags", type: "table", required: false}], returns: "table | nil"} + %{ + module: "app", + name: "copy_to_clipboard", + description: "Copy text to the system clipboard.", + params: [%{name: "text", type: "string", required: true}], + returns: "boolean" + }, + %{ + module: "app", + name: "get_blogmark_bookmarklet", + description: "Return the Blogmark bookmarklet JavaScript source.", + params: [], + returns: "string" + }, + %{ + module: "app", + name: "get_data_paths", + description: "Return filesystem paths for the current application and project data.", + params: [], + returns: "table" + }, + %{ + module: "app", + name: "get_default_project_path", + description: "Return the current project's filesystem path.", + params: [], + returns: "string | nil" + }, + %{ + module: "app", + name: "get_system_language", + description: "Return the current UI locale.", + params: [], + returns: "string | nil" + }, + %{ + module: "app", + name: "get_title_bar_metrics", + description: "Return desktop title bar inset metrics when available.", + params: [], + returns: "table | nil" + }, + %{ + module: "app", + name: "notify_renderer_ready", + description: "Notify the host application that the renderer is ready.", + params: [], + returns: "boolean" + }, + %{ + module: "app", + name: "open_folder", + description: "Open a folder in the system file manager.", + params: [%{name: "folder_path", type: "string", required: true}], + returns: "string" + }, + %{ + module: "app", + name: "read_project_metadata", + description: "Read project metadata from a project folder path.", + params: [%{name: "folder_path", type: "string", required: true}], + returns: "ProjectMetadata | nil" + }, + %{ + module: "app", + name: "select_folder", + description: "Show the native folder picker and return the chosen path.", + params: [%{name: "title", type: "string", required: false}], + returns: "string | nil" + }, + %{ + module: "app", + name: "set_preview_post_target", + description: "Set the current preview-post target used by desktop integrations.", + params: [%{name: "post_id", type: "string", required: false}], + returns: "boolean" + }, + %{ + module: "app", + name: "show_item_in_folder", + description: "Reveal a file or folder in the system file manager.", + params: [%{name: "item_path", type: "string", required: true}], + returns: "nil" + }, + %{ + module: "app", + name: "trigger_menu_action", + description: "Trigger a native menu action by action id.", + params: [%{name: "action", type: "string", required: true}], + returns: "nil" + }, + %{ + module: "projects", + name: "create", + description: "Create a project.", + params: [%{name: "data", type: "table", required: true}], + returns: "ProjectData | nil" + }, + %{ + module: "projects", + name: "delete", + description: "Delete a project by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "boolean" + }, + %{ + module: "projects", + name: "delete_with_data", + description: "Delete a project by id and remove its project directory.", + params: [%{name: "id", type: "string", required: true}], + returns: "boolean" + }, + %{ + module: "projects", + name: "get", + description: "Fetch one project by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "ProjectData | nil" + }, + %{ + module: "projects", + name: "get_all", + description: "Fetch all projects.", + params: [], + returns: "ProjectData[]" + }, + %{ + module: "projects", + name: "get_active", + description: "Fetch the active project.", + params: [], + returns: "ProjectData | nil" + }, + %{ + module: "projects", + name: "set_active", + description: "Set the active project by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "ProjectData | nil" + }, + %{ + module: "projects", + name: "update", + description: "Update a project by id.", + params: [ + %{name: "id", type: "string", required: true}, + %{name: "data", type: "table", required: true} + ], + returns: "ProjectData | nil" + }, + %{ + module: "posts", + name: "create", + description: "Create a post in the current project.", + params: [%{name: "data", type: "table", required: true}], + returns: "PostData | nil" + }, + %{ + module: "posts", + name: "update", + description: "Update a post by id.", + params: [ + %{name: "id", type: "string", required: true}, + %{name: "data", type: "table", required: true} + ], + returns: "PostData | nil" + }, + %{ + module: "posts", + name: "delete", + description: "Delete a post by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "boolean" + }, + %{ + module: "posts", + name: "discard", + description: + "Discard unpublished post changes and restore the last published version from disk.", + params: [%{name: "id", type: "string", required: true}], + returns: "PostData | nil" + }, + %{ + module: "posts", + name: "filter", + description: + "Filter posts using status, tags, categories, language, year, month, or date range fields.", + params: [%{name: "filters", type: "table", required: true}], + returns: "PostData[] | nil" + }, + %{ + module: "posts", + name: "generate_unique_slug", + description: "Generate a unique slug from a title, optionally excluding one post id.", + params: [ + %{name: "title", type: "string", required: true}, + %{name: "exclude_post_id", type: "string", required: false} + ], + returns: "string" + }, + %{ + module: "posts", + name: "get", + description: "Fetch one post by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "PostData | nil" + }, + %{ + module: "posts", + name: "get_all", + description: "Fetch all posts in the current project.", + params: [], + returns: "PostData[]" + }, + %{ + module: "posts", + name: "get_by_slug", + description: "Fetch one post by slug.", + params: [%{name: "slug", type: "string", required: true}], + returns: "PostData | nil" + }, + %{ + module: "posts", + name: "get_by_status", + description: "Fetch posts filtered by a specific status.", + params: [%{name: "status", type: "string", required: true}], + returns: "PostData[]" + }, + %{ + module: "posts", + name: "get_by_year_month", + description: "Get post counts grouped by year and month.", + params: [], + returns: "table[]" + }, + %{ + module: "posts", + name: "get_categories", + description: "Get category names used by posts in the current project.", + params: [], + returns: "string[]" + }, + %{ + module: "posts", + name: "get_categories_with_counts", + description: "Get post categories with usage counts.", + params: [], + returns: "table[]" + }, + %{ + module: "posts", + name: "get_dashboard_stats", + description: "Return aggregate post dashboard counts for the current project.", + params: [], + returns: "table" + }, + %{ + module: "posts", + name: "get_linked_by", + description: "Return posts that link to the given post.", + params: [%{name: "post_id", type: "string", required: true}], + returns: "table[]" + }, + %{ + module: "posts", + name: "get_links_to", + description: "Return posts linked from the given post.", + params: [%{name: "post_id", type: "string", required: true}], + returns: "table[]" + }, + %{ + module: "posts", + name: "get_preview_url", + description: + "Return the local preview URL for a post, optionally with draft and language query parameters.", + params: [ + %{name: "post_id", type: "string", required: true}, + %{name: "options", type: "table", required: false} + ], + returns: "string | nil" + }, + %{ + module: "posts", + name: "get_tags", + description: "Get tag names used by posts in the current project.", + params: [], + returns: "string[]" + }, + %{ + module: "posts", + name: "get_tags_with_counts", + description: "Get post tags with usage counts.", + params: [], + returns: "table[]" + }, + %{ + module: "posts", + name: "get_translation", + description: "Get a single translation for a post by language.", + params: [ + %{name: "post_id", type: "string", required: true}, + %{name: "language", type: "string", required: true} + ], + returns: "table | nil" + }, + %{ + module: "posts", + name: "get_translations", + description: "Get all translations for a post.", + params: [%{name: "post_id", type: "string", required: true}], + returns: "table[]" + }, + %{ + module: "posts", + name: "has_published_version", + description: "Check whether a post has a published version.", + params: [%{name: "post_id", type: "string", required: true}], + returns: "boolean" + }, + %{ + module: "posts", + name: "is_slug_available", + description: + "Return whether a slug is available in the current project, optionally excluding one post id.", + params: [ + %{name: "slug", type: "string", required: true}, + %{name: "exclude_post_id", type: "string", required: false} + ], + returns: "boolean" + }, + %{ + module: "posts", + name: "publish", + description: "Publish a post by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "PostData | nil" + }, + %{ + module: "posts", + name: "publish_translation", + description: "Publish one translation of a post by language.", + params: [ + %{name: "post_id", type: "string", required: true}, + %{name: "language", type: "string", required: true} + ], + returns: "table | nil" + }, + %{ + module: "posts", + name: "rebuild_from_files", + description: "Rebuild post records from published files.", + params: [], + returns: "PostData[] | nil" + }, + %{ + module: "posts", + name: "rebuild_links", + description: "Rebuild the post link graph for the current project.", + params: [], + returns: "boolean" + }, + %{ + module: "posts", + name: "reindex_text", + description: "Reindex post and media search text for the current project.", + params: [], + returns: "boolean" + }, + %{ + module: "posts", + name: "search", + description: "Search posts by free-text query.", + params: [%{name: "query", type: "string", required: true}], + returns: "PostData[] | nil" + }, + %{ + module: "media", + name: "delete_translation", + description: "Delete a media translation by language.", + params: [ + %{name: "media_id", type: "string", required: true}, + %{name: "language", type: "string", required: true} + ], + returns: "boolean" + }, + %{ + module: "media", + name: "filter", + description: "Filter media using year, month, tags, language, or date range fields.", + params: [%{name: "filters", type: "table", required: true}], + returns: "MediaData[]" + }, + %{ + module: "media", + name: "import", + description: "Import media into the current project.", + params: [%{name: "data", type: "table", required: true}], + returns: "MediaData | nil" + }, + %{ + module: "media", + name: "get_by_year_month", + description: "Get media counts grouped by year and month.", + params: [], + returns: "table[]" + }, + %{ + module: "media", + name: "get_file_path", + description: "Return the absolute file path for a media item.", + params: [%{name: "media_id", type: "string", required: true}], + returns: "string | nil" + }, + %{ + module: "media", + name: "update", + description: "Update media metadata by id.", + params: [ + %{name: "id", type: "string", required: true}, + %{name: "data", type: "table", required: true} + ], + returns: "MediaData | nil" + }, + %{ + module: "media", + name: "delete", + description: "Delete a media item by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "boolean" + }, + %{ + module: "media", + name: "get", + description: "Fetch one media item by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "MediaData | nil" + }, + %{ + module: "media", + name: "get_all", + description: "Fetch all media in the current project.", + params: [], + returns: "MediaData[]" + }, + %{ + module: "media", + name: "get_tags", + description: "Return tag names used by media in the current project.", + params: [], + returns: "string[]" + }, + %{ + module: "media", + name: "get_tags_with_counts", + description: "Return media tags with usage counts.", + params: [], + returns: "table[]" + }, + %{ + module: "media", + name: "get_thumbnail", + description: "Return a media thumbnail as a data URL for the requested size.", + params: [ + %{name: "media_id", type: "string", required: true}, + %{name: "size", type: "string", required: false} + ], + returns: "string | nil" + }, + %{ + module: "media", + name: "get_translation", + description: "Return one media translation by language.", + params: [ + %{name: "media_id", type: "string", required: true}, + %{name: "language", type: "string", required: true} + ], + returns: "table | nil" + }, + %{ + module: "media", + name: "get_translations", + description: "Return all translations for a media item.", + params: [%{name: "media_id", type: "string", required: true}], + returns: "table[]" + }, + %{ + module: "media", + name: "get_url", + description: "Return the project-relative public URL path for a media item.", + params: [%{name: "media_id", type: "string", required: true}], + returns: "string | nil" + }, + %{ + module: "media", + name: "rebuild_from_files", + description: "Rebuild media records from sidecar files on disk.", + params: [], + returns: "MediaData[] | nil" + }, + %{ + module: "media", + name: "regenerate_missing_thumbnails", + description: "Generate thumbnails for media items that are missing them.", + params: [], + returns: "table" + }, + %{ + module: "media", + name: "regenerate_thumbnails", + description: "Regenerate all thumbnails for one media item.", + params: [%{name: "media_id", type: "string", required: true}], + returns: "table | nil" + }, + %{ + module: "media", + name: "reindex_text", + description: "Reindex post and media search text for the current project.", + params: [], + returns: "boolean" + }, + %{ + module: "media", + name: "replace_file", + description: "Replace the binary file behind an existing media item.", + params: [ + %{name: "media_id", type: "string", required: true}, + %{name: "source_path", type: "string", required: true} + ], + returns: "MediaData | nil" + }, + %{ + module: "media", + name: "search", + description: "Search media by free-text query.", + params: [%{name: "query", type: "string", required: true}], + returns: "MediaData[] | nil" + }, + %{ + module: "media", + name: "upsert_translation", + description: "Create or update a media translation.", + params: [ + %{name: "media_id", type: "string", required: true}, + %{name: "language", type: "string", required: true}, + %{name: "data", type: "table", required: true} + ], + returns: "table | nil" + }, + %{ + module: "scripts", + name: "create", + description: "Create a script in the current project.", + params: [%{name: "data", type: "table", required: true}], + returns: "ScriptData | nil" + }, + %{ + module: "scripts", + name: "update", + description: "Update a script by id.", + params: [ + %{name: "id", type: "string", required: true}, + %{name: "data", type: "table", required: true} + ], + returns: "ScriptData | nil" + }, + %{ + module: "scripts", + name: "delete", + description: "Delete a script by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "boolean" + }, + %{ + module: "scripts", + name: "get", + description: "Fetch one script by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "ScriptData | nil" + }, + %{ + module: "scripts", + name: "get_all", + description: "Fetch all scripts in the current project.", + params: [], + returns: "ScriptData[]" + }, + %{ + module: "scripts", + name: "publish", + description: "Publish a script by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "ScriptData | nil" + }, + %{ + module: "scripts", + name: "rebuild_from_files", + description: "Rebuild script records from published files.", + params: [], + returns: "ScriptData[] | nil" + }, + %{ + module: "templates", + name: "create", + description: "Create a template in the current project.", + params: [%{name: "data", type: "table", required: true}], + returns: "TemplateData | nil" + }, + %{ + module: "templates", + name: "update", + description: "Update a template by id.", + params: [ + %{name: "id", type: "string", required: true}, + %{name: "data", type: "table", required: true} + ], + returns: "TemplateData | nil" + }, + %{ + module: "templates", + name: "delete", + description: "Delete a template by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "boolean" + }, + %{ + module: "templates", + name: "get", + description: "Fetch one template by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "TemplateData | nil" + }, + %{ + module: "templates", + name: "get_all", + description: "Fetch all templates in the current project.", + params: [], + returns: "TemplateData[]" + }, + %{ + module: "templates", + name: "get_enabled_by_kind", + description: "Fetch enabled templates filtered by kind.", + params: [%{name: "kind", type: "string", required: true}], + returns: "TemplateData[]" + }, + %{ + module: "templates", + name: "publish", + description: "Publish a template by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "TemplateData | nil" + }, + %{ + module: "templates", + name: "rebuild_from_files", + description: "Rebuild template records from published files.", + params: [], + returns: "TemplateData[] | nil" + }, + %{ + module: "templates", + name: "validate", + description: "Validate Liquid template syntax.", + params: [%{name: "content", type: "string", required: true}], + returns: "ValidationResult | nil" + }, + %{ + module: "meta", + name: "add_category", + description: "Add a category to the current project.", + params: [%{name: "name", type: "string", required: true}], + returns: "ProjectMetadata | nil" + }, + %{ + module: "meta", + name: "add_tag", + description: "Add a tag record to the current project if it does not already exist.", + params: [%{name: "name", type: "string", required: true}], + returns: "string[]" + }, + %{ + module: "meta", + name: "clear_publishing_preferences", + description: "Reset publishing preferences to defaults.", + params: [], + returns: "table | nil" + }, + %{ + module: "meta", + name: "get_categories", + description: "Get project categories.", + params: [], + returns: "string[]" + }, + %{ + module: "meta", + name: "get_project_metadata", + description: "Read metadata for the current project.", + params: [], + returns: "ProjectMetadata" + }, + %{ + module: "meta", + name: "get_publishing_preferences", + description: "Get publishing preferences for the current project.", + params: [], + returns: "table | nil" + }, + %{ + module: "meta", + name: "get_tags", + description: "Get tag names for the current project.", + params: [], + returns: "string[]" + }, + %{ + module: "meta", + name: "remove_category", + description: "Remove a category from the current project.", + params: [%{name: "name", type: "string", required: true}], + returns: "ProjectMetadata | nil" + }, + %{ + module: "meta", + name: "remove_tag", + description: "Remove a tag record from the current project by name.", + params: [%{name: "name", type: "string", required: true}], + returns: "string[]" + }, + %{ + module: "meta", + name: "set_project_metadata", + description: "Replace project metadata fields for the current project.", + params: [%{name: "updates", type: "table", required: true}], + returns: "ProjectMetadata | nil" + }, + %{ + module: "meta", + name: "set_publishing_preferences", + description: "Set publishing preferences for the current project.", + params: [%{name: "prefs", type: "table", required: true}], + returns: "table | nil" + }, + %{ + module: "meta", + name: "sync_on_startup", + description: + "Synchronize startup metadata state and return tags, categories, and project metadata.", + params: [], + returns: "table" + }, + %{ + module: "meta", + name: "update_project_metadata", + description: "Update metadata for the current project.", + params: [%{name: "updates", type: "table", required: true}], + returns: "ProjectMetadata | nil" + }, + %{ + module: "tags", + name: "create", + description: "Create a tag in the current project.", + params: [%{name: "data", type: "table", required: true}], + returns: "TagData | nil" + }, + %{ + module: "tags", + name: "update", + description: "Update a tag by id.", + params: [ + %{name: "id", type: "string", required: true}, + %{name: "data", type: "table", required: true} + ], + returns: "TagData | nil" + }, + %{ + module: "tags", + name: "delete", + description: "Delete a tag by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "boolean" + }, + %{ + module: "tags", + name: "get", + description: "Fetch one tag by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "TagData | nil" + }, + %{ + module: "tags", + name: "get_all", + description: "Fetch all tags in the current project.", + params: [], + returns: "TagData[]" + }, + %{ + module: "tags", + name: "get_by_name", + description: "Fetch one tag by name.", + params: [%{name: "name", type: "string", required: true}], + returns: "TagData | nil" + }, + %{ + module: "tags", + name: "get_posts_with_tag", + description: "Get post ids using a specific tag.", + params: [%{name: "tag_id", type: "string", required: true}], + returns: "string[]" + }, + %{ + module: "tags", + name: "get_with_counts", + description: "Fetch tags with usage counts.", + params: [], + returns: "table[]" + }, + %{ + module: "tags", + name: "merge", + description: "Merge source tags into a target tag.", + params: [ + %{name: "source_tag_ids", type: "table", required: true}, + %{name: "target_tag_id", type: "string", required: true} + ], + returns: "boolean" + }, + %{ + module: "tags", + name: "rename", + description: "Rename a tag by id.", + params: [ + %{name: "id", type: "string", required: true}, + %{name: "new_name", type: "string", required: true} + ], + returns: "TagData | nil" + }, + %{ + module: "tags", + name: "sync_from_posts", + description: "Sync tag records from post tags.", + params: [], + returns: "TagData[] | nil" + }, + %{ + module: "tasks", + name: "cancel", + description: "Cancel a task by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "boolean" + }, + %{ + module: "tasks", + name: "clear_completed", + description: "Clear completed tasks from the in-memory task list.", + params: [], + returns: "boolean" + }, + %{ + module: "tasks", + name: "get", + description: "Fetch one task by id.", + params: [%{name: "id", type: "string", required: true}], + returns: "TaskData | nil" + }, + %{ + module: "tasks", + name: "get_all", + description: "Fetch all tasks currently tracked by the task manager.", + params: [], + returns: "TaskData[]" + }, + %{ + module: "tasks", + name: "get_running", + description: "Fetch running tasks currently tracked by the task manager.", + params: [], + returns: "TaskData[]" + }, + %{ + module: "tasks", + name: "status_snapshot", + description: "Fetch the current task status snapshot.", + params: [], + returns: "TaskStatus" + }, + %{ + module: "sync", + name: "check_availability", + description: "Return whether Git is available on the current machine.", + params: [], + returns: "boolean" + }, + %{ + module: "sync", + name: "commit_all", + description: "Commit all pending repository changes for the current project.", + params: [%{name: "message", type: "string", required: true}], + returns: "table | nil" + }, + %{ + module: "sync", + name: "fetch", + description: "Fetch remote Git refs for the current project.", + params: [], + returns: "table | nil" + }, + %{ + module: "sync", + name: "get_history", + description: "Return commit history for the current project repository.", + params: [], + returns: "table | nil" + }, + %{ + module: "sync", + name: "get_remote_state", + description: "Return remote repository state information for the current project.", + params: [], + returns: "table | nil" + }, + %{ + module: "sync", + name: "get_repo_state", + description: "Return repository state information for the current project.", + params: [], + returns: "table | nil" + }, + %{ + module: "sync", + name: "get_status", + description: "Return Git status information for the current project.", + params: [], + returns: "table | nil" + }, + %{ + module: "sync", + name: "pull", + description: "Pull remote changes for the current project.", + params: [], + returns: "table | nil" + }, + %{ + module: "sync", + name: "push", + description: "Push local changes for the current project.", + params: [], + returns: "table | nil" + }, + %{ + module: "publish", + name: "upload_site", + description: "Upload the rendered site using the provided publishing credentials.", + params: [%{name: "credentials", type: "table", required: true}], + returns: "TaskData | nil" + }, + %{ + module: "chat", + name: "analyze_media_image", + description: "Analyze a media image using the configured AI runtime.", + params: [%{name: "media_id", type: "string", required: true}], + returns: "table | nil" + }, + %{ + module: "chat", + name: "analyze_post", + description: "Analyze a post using the configured AI runtime.", + params: [%{name: "post_id", type: "string", required: true}], + returns: "table | nil" + }, + %{ + module: "chat", + name: "detect_media_language", + description: "Detect the language of media metadata.", + params: [ + %{name: "title", type: "string", required: true}, + %{name: "alt", type: "string", required: false}, + %{name: "caption", type: "string", required: false} + ], + returns: "table" + }, + %{ + module: "chat", + name: "detect_post_language", + description: "Detect the language of post title and content.", + params: [ + %{name: "title", type: "string", required: true}, + %{name: "content", type: "string", required: true} + ], + returns: "table" + }, + %{ + module: "chat", + name: "translate_media_metadata", + description: "Translate media metadata and persist the translation.", + params: [ + %{name: "media_id", type: "string", required: true}, + %{name: "language", type: "string", required: true} + ], + returns: "table | nil" + }, + %{ + module: "chat", + name: "translate_post", + description: "Translate a post and persist the translation.", + params: [ + %{name: "post_id", type: "string", required: true}, + %{name: "language", type: "string", required: true} + ], + returns: "table | nil" + }, + %{ + module: "embeddings", + name: "compute_similarities", + description: "Compute similarity scores from one source post to target posts.", + params: [ + %{name: "post_id", type: "string", required: true}, + %{name: "target_ids", type: "table", required: true} + ], + returns: "table | nil" + }, + %{ + module: "embeddings", + name: "dismiss_pair", + description: "Dismiss a duplicate candidate pair.", + params: [ + %{name: "post_id_a", type: "string", required: true}, + %{name: "post_id_b", type: "string", required: true} + ], + returns: "boolean" + }, + %{ + module: "embeddings", + name: "find_duplicates", + description: "Find duplicate post candidates for the current project.", + params: [], + returns: "table | nil" + }, + %{ + module: "embeddings", + name: "find_similar", + description: "Find posts similar to the given post id.", + params: [ + %{name: "post_id", type: "string", required: true}, + %{name: "limit", type: "integer", required: false} + ], + returns: "table | nil" + }, + %{ + module: "embeddings", + name: "get_progress", + description: "Get embedding index progress for the current project.", + params: [], + returns: "table | nil" + }, + %{ + module: "embeddings", + name: "index_unindexed_posts", + description: "Index posts missing embeddings for the current project.", + params: [], + returns: "table | nil" + }, + %{ + module: "embeddings", + name: "suggest_tags", + description: "Suggest tags for a post from semantic similarity.", + params: [ + %{name: "post_id", type: "string", required: true}, + %{name: "exclude_tags", type: "table", required: false} + ], + returns: "table | nil" + } ] @data_structures [ - %{name: "ProjectData", description: "Project record stored in the application database.", fields: [%{name: "id", type: "string"}, %{name: "name", type: "string"}, %{name: "slug", type: "string"}, %{name: "description", type: "string | nil"}, %{name: "data_path", type: "string | nil"}, %{name: "is_active", type: "boolean"}, %{name: "created_at", type: "integer"}, %{name: "updated_at", type: "integer"}]}, - %{name: "ProjectMetadata", description: "Current project metadata and publishing settings snapshot.", fields: [%{name: "name", type: "string"}, %{name: "description", type: "string | nil"}, %{name: "public_url", type: "string | nil"}, %{name: "main_language", type: "string | nil"}, %{name: "default_author", type: "string | nil"}, %{name: "categories", type: "string[]"}, %{name: "blog_languages", type: "string[]"}, %{name: "publishing_preferences", type: "table"}]}, - %{name: "PostData", description: "Post record with link graph data added for scripting.", fields: [%{name: "id", type: "string"}, %{name: "project_id", type: "string"}, %{name: "title", type: "string"}, %{name: "slug", type: "string"}, %{name: "status", type: "string"}, %{name: "language", type: "string | nil"}, %{name: "tags", type: "string[]"}, %{name: "categories", type: "string[]"}, %{name: "backlinks", type: "table[]"}, %{name: "links_to", type: "table[]"}]}, - %{name: "MediaData", description: "Media record stored for a project.", fields: [%{name: "id", type: "string"}, %{name: "project_id", type: "string"}, %{name: "original_name", type: "string"}, %{name: "mime_type", type: "string"}, %{name: "file_path", type: "string"}, %{name: "title", type: "string | nil"}, %{name: "alt", type: "string | nil"}, %{name: "caption", type: "string | nil"}, %{name: "tags", type: "string[]"}]}, - %{name: "ScriptData", description: "Lua script record.", fields: [%{name: "id", type: "string"}, %{name: "project_id", type: "string"}, %{name: "slug", type: "string"}, %{name: "title", type: "string"}, %{name: "kind", type: "string"}, %{name: "entrypoint", type: "string"}, %{name: "enabled", type: "boolean"}, %{name: "status", type: "string"}]}, - %{name: "TemplateData", description: "Template record for site rendering.", fields: [%{name: "id", type: "string"}, %{name: "project_id", type: "string"}, %{name: "slug", type: "string"}, %{name: "title", type: "string"}, %{name: "kind", type: "string"}, %{name: "enabled", type: "boolean"}, %{name: "status", type: "string"}]}, - %{name: "TagData", description: "Tag record stored for a project.", fields: [%{name: "id", type: "string"}, %{name: "project_id", type: "string"}, %{name: "name", type: "string"}, %{name: "color", type: "string | nil"}, %{name: "post_template_slug", type: "string | nil"}]}, - %{name: "TaskData", description: "Public task snapshot returned by the task manager.", fields: [%{name: "id", type: "string"}, %{name: "name", type: "string"}, %{name: "status", type: "string"}, %{name: "progress", type: "number | table | nil"}, %{name: "message", type: "string | nil"}]}, - %{name: "TaskStatus", description: "Aggregate task status snapshot.", fields: [%{name: "active_count", type: "integer"}, %{name: "running_count", type: "integer"}, %{name: "pending_count", type: "integer"}, %{name: "tasks", type: "TaskData[]"}]}, - %{name: "ValidationResult", description: "Template validation result.", fields: [%{name: "valid", type: "boolean"}, %{name: "errors", type: "string[]"}]} + %{ + name: "ProjectData", + description: "Project record stored in the application database.", + fields: [ + %{name: "id", type: "string"}, + %{name: "name", type: "string"}, + %{name: "slug", type: "string"}, + %{name: "description", type: "string | nil"}, + %{name: "data_path", type: "string | nil"}, + %{name: "is_active", type: "boolean"}, + %{name: "created_at", type: "integer"}, + %{name: "updated_at", type: "integer"} + ] + }, + %{ + name: "ProjectMetadata", + description: "Current project metadata and publishing settings snapshot.", + fields: [ + %{name: "name", type: "string"}, + %{name: "description", type: "string | nil"}, + %{name: "public_url", type: "string | nil"}, + %{name: "main_language", type: "string | nil"}, + %{name: "default_author", type: "string | nil"}, + %{name: "categories", type: "string[]"}, + %{name: "blog_languages", type: "string[]"}, + %{name: "publishing_preferences", type: "table"} + ] + }, + %{ + name: "PostData", + description: "Post record with link graph data added for scripting.", + fields: [ + %{name: "id", type: "string"}, + %{name: "project_id", type: "string"}, + %{name: "title", type: "string"}, + %{name: "slug", type: "string"}, + %{name: "status", type: "string"}, + %{name: "language", type: "string | nil"}, + %{name: "tags", type: "string[]"}, + %{name: "categories", type: "string[]"}, + %{name: "backlinks", type: "table[]"}, + %{name: "links_to", type: "table[]"} + ] + }, + %{ + name: "MediaData", + description: "Media record stored for a project.", + fields: [ + %{name: "id", type: "string"}, + %{name: "project_id", type: "string"}, + %{name: "original_name", type: "string"}, + %{name: "mime_type", type: "string"}, + %{name: "file_path", type: "string"}, + %{name: "title", type: "string | nil"}, + %{name: "alt", type: "string | nil"}, + %{name: "caption", type: "string | nil"}, + %{name: "tags", type: "string[]"} + ] + }, + %{ + name: "ScriptData", + description: "Lua script record.", + fields: [ + %{name: "id", type: "string"}, + %{name: "project_id", type: "string"}, + %{name: "slug", type: "string"}, + %{name: "title", type: "string"}, + %{name: "kind", type: "string"}, + %{name: "entrypoint", type: "string"}, + %{name: "enabled", type: "boolean"}, + %{name: "status", type: "string"} + ] + }, + %{ + name: "TemplateData", + description: "Template record for site rendering.", + fields: [ + %{name: "id", type: "string"}, + %{name: "project_id", type: "string"}, + %{name: "slug", type: "string"}, + %{name: "title", type: "string"}, + %{name: "kind", type: "string"}, + %{name: "enabled", type: "boolean"}, + %{name: "status", type: "string"} + ] + }, + %{ + name: "TagData", + description: "Tag record stored for a project.", + fields: [ + %{name: "id", type: "string"}, + %{name: "project_id", type: "string"}, + %{name: "name", type: "string"}, + %{name: "color", type: "string | nil"}, + %{name: "post_template_slug", type: "string | nil"} + ] + }, + %{ + name: "TaskData", + description: "Public task snapshot returned by the task manager.", + fields: [ + %{name: "id", type: "string"}, + %{name: "name", type: "string"}, + %{name: "status", type: "string"}, + %{name: "progress", type: "number | table | nil"}, + %{name: "message", type: "string | nil"} + ] + }, + %{ + name: "TaskStatus", + description: "Aggregate task status snapshot.", + fields: [ + %{name: "active_count", type: "integer"}, + %{name: "running_count", type: "integer"}, + %{name: "pending_count", type: "integer"}, + %{name: "tasks", type: "TaskData[]"} + ] + }, + %{ + name: "ValidationResult", + description: "Template validation result.", + fields: [%{name: "valid", type: "boolean"}, %{name: "errors", type: "string[]"}] + } ] def render do diff --git a/lib/bds/scripting/capabilities.ex b/lib/bds/scripting/capabilities.ex index 2931f17..5aa5af9 100644 --- a/lib/bds/scripting/capabilities.ex +++ b/lib/bds/scripting/capabilities.ex @@ -39,64 +39,93 @@ defmodule BDS.Scripting.Capabilities do projects: %{ create: zero_or_one_arg(fn attrs -> create_project(attrs) end), delete: one_arg(fn project_id_to_delete -> delete_project(project_id_to_delete) end), - delete_with_data: one_arg(fn project_id_to_delete -> delete_project_with_data(project_id_to_delete) end), + delete_with_data: + one_arg(fn project_id_to_delete -> delete_project_with_data(project_id_to_delete) end), get: one_arg(fn project_id_to_load -> load_project(project_id_to_load) end), get_all: zero_or_one_arg(fn _args -> list_projects() end), get_active: zero_or_one_arg(fn _args -> load_project(project_id) end), - set_active: one_arg(fn project_id_to_activate -> set_active_project(project_id_to_activate) end), - update: two_arg(fn project_id_to_update, attrs -> update_project(project_id_to_update, attrs) end) + set_active: + one_arg(fn project_id_to_activate -> set_active_project(project_id_to_activate) end), + update: + two_arg(fn project_id_to_update, attrs -> + update_project(project_id_to_update, attrs) + end) }, meta: %{ get_project_metadata: zero_or_one_arg(fn _args -> load_metadata(project_id) end), - update_project_metadata: one_arg(fn attrs -> update_project_metadata(project_id, attrs) end), + update_project_metadata: + one_arg(fn attrs -> update_project_metadata(project_id, attrs) end), add_category: one_arg(fn name -> add_category(project_id, name) end), remove_category: one_arg(fn name -> remove_category(project_id, name) end), add_tag: one_arg(fn name -> add_meta_tag(project_id, name) end), get_categories: zero_or_one_arg(fn _args -> metadata_categories(project_id) end), set_project_metadata: one_arg(fn attrs -> update_project_metadata(project_id, attrs) end), - get_publishing_preferences: zero_or_one_arg(fn _args -> publishing_preferences(project_id) end), + get_publishing_preferences: + zero_or_one_arg(fn _args -> publishing_preferences(project_id) end), get_tags: zero_or_one_arg(fn _args -> metadata_tags(project_id) end), remove_tag: one_arg(fn name -> remove_meta_tag(project_id, name) end), - set_publishing_preferences: one_arg(fn prefs -> set_publishing_preferences(project_id, prefs) end), - clear_publishing_preferences: zero_or_one_arg(fn _args -> clear_publishing_preferences(project_id) end), + set_publishing_preferences: + one_arg(fn prefs -> set_publishing_preferences(project_id, prefs) end), + clear_publishing_preferences: + zero_or_one_arg(fn _args -> clear_publishing_preferences(project_id) end), sync_on_startup: zero_or_one_arg(fn _args -> sync_meta_on_startup(project_id) end) }, posts: %{ create: one_arg(fn attrs -> create_post(project_id, attrs) end), discard: one_arg(fn post_id -> discard_post(project_id, post_id) end), filter: one_arg(fn filters -> filter_posts(project_id, filters) end), - generate_unique_slug: two_arg(fn title, exclude_post_id -> generate_unique_post_slug(project_id, title, exclude_post_id) end), + generate_unique_slug: + two_arg(fn title, exclude_post_id -> + generate_unique_post_slug(project_id, title, exclude_post_id) + end), get_by_status: one_arg(fn status -> posts_by_status(project_id, status) end), get_by_year_month: zero_or_one_arg(fn _args -> post_counts_by_year_month(project_id) end), get_dashboard_stats: zero_or_one_arg(fn _args -> post_dashboard_stats(project_id) end), - get_linked_by: one_arg(fn post_id -> linked_posts_for(project_id, post_id, :incoming) end), + get_linked_by: + one_arg(fn post_id -> linked_posts_for(project_id, post_id, :incoming) end), get_links_to: one_arg(fn post_id -> linked_posts_for(project_id, post_id, :outgoing) end), - get_preview_url: two_arg(fn post_id, options -> preview_url(project_id, post_id, options) end), + get_preview_url: + two_arg(fn post_id, options -> preview_url(project_id, post_id, options) end), update: two_arg(fn post_id, attrs -> update_post(project_id, post_id, attrs) end), delete: one_arg(fn post_id -> delete_post(project_id, post_id) end), get: one_arg(fn post_id -> load_post(project_id, post_id) end), get_all: zero_or_one_arg(fn _args -> list_posts(project_id) end), get_by_slug: one_arg(fn slug -> load_post_by_slug(project_id, slug) end), get_categories: zero_or_one_arg(fn _args -> post_categories(project_id) end), - get_categories_with_counts: zero_or_one_arg(fn _args -> post_categories_with_counts(project_id) end), + get_categories_with_counts: + zero_or_one_arg(fn _args -> post_categories_with_counts(project_id) end), get_tags: zero_or_one_arg(fn _args -> post_tags(project_id) end), get_tags_with_counts: zero_or_one_arg(fn _args -> post_tags_with_counts(project_id) end), - get_translation: two_arg(fn post_id, language -> load_post_translation(project_id, post_id, language) end), + get_translation: + two_arg(fn post_id, language -> + load_post_translation(project_id, post_id, language) + end), get_translations: one_arg(fn post_id -> list_post_translations(project_id, post_id) end), - has_published_version: one_arg(fn post_id -> has_published_post_version(project_id, post_id) end), - is_slug_available: two_arg(fn slug, exclude_post_id -> post_slug_available?(project_id, slug, exclude_post_id) end), + has_published_version: + one_arg(fn post_id -> has_published_post_version(project_id, post_id) end), + is_slug_available: + two_arg(fn slug, exclude_post_id -> + post_slug_available?(project_id, slug, exclude_post_id) + end), publish: one_arg(fn post_id -> publish_post(project_id, post_id) end), - publish_translation: two_arg(fn post_id, language -> publish_post_translation(project_id, post_id, language) end), + publish_translation: + two_arg(fn post_id, language -> + publish_post_translation(project_id, post_id, language) + end), rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_posts_from_files(project_id) end), rebuild_links: zero_or_one_arg(fn _args -> rebuild_post_links(project_id) end), reindex_text: zero_or_one_arg(fn _args -> reindex_project_search(project_id) end), search: one_arg(fn query -> search_posts(project_id, query) end) }, media: %{ - delete_translation: two_arg(fn media_id, language -> delete_media_translation(project_id, media_id, language) end), + delete_translation: + two_arg(fn media_id, language -> + delete_media_translation(project_id, media_id, language) + end), filter: one_arg(fn filters -> filter_media(project_id, filters) end), import: one_arg(fn attrs -> import_media(project_id, attrs) end), - get_by_year_month: zero_or_one_arg(fn _args -> media_counts_by_year_month(project_id) end), + get_by_year_month: + zero_or_one_arg(fn _args -> media_counts_by_year_month(project_id) end), get_file_path: one_arg(fn media_id -> media_file_path(project_id, media_id) end), update: two_arg(fn media_id, attrs -> update_media(project_id, media_id, attrs) end), delete: one_arg(fn media_id -> delete_media(project_id, media_id) end), @@ -104,17 +133,30 @@ defmodule BDS.Scripting.Capabilities do get_all: zero_or_one_arg(fn _args -> list_media(project_id) end), get_tags: zero_or_one_arg(fn _args -> media_tags(project_id) end), get_tags_with_counts: zero_or_one_arg(fn _args -> media_tags_with_counts(project_id) end), - get_thumbnail: two_arg(fn media_id, size -> media_thumbnail(project_id, media_id, size) end), - get_translation: two_arg(fn media_id, language -> load_media_translation(project_id, media_id, language) end), - get_translations: one_arg(fn media_id -> list_media_translations(project_id, media_id) end), + get_thumbnail: + two_arg(fn media_id, size -> media_thumbnail(project_id, media_id, size) end), + get_translation: + two_arg(fn media_id, language -> + load_media_translation(project_id, media_id, language) + end), + get_translations: + one_arg(fn media_id -> list_media_translations(project_id, media_id) end), get_url: one_arg(fn media_id -> media_url(project_id, media_id) end), rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_media_from_files(project_id) end), - regenerate_missing_thumbnails: zero_or_one_arg(fn _args -> regenerate_missing_thumbnails(project_id) end), - regenerate_thumbnails: one_arg(fn media_id -> regenerate_media_thumbnails(project_id, media_id) end), + regenerate_missing_thumbnails: + zero_or_one_arg(fn _args -> regenerate_missing_thumbnails(project_id) end), + regenerate_thumbnails: + one_arg(fn media_id -> regenerate_media_thumbnails(project_id, media_id) end), reindex_text: zero_or_one_arg(fn _args -> reindex_project_search(project_id) end), - replace_file: two_arg(fn media_id, source_path -> replace_media_file(project_id, media_id, source_path) end), + replace_file: + two_arg(fn media_id, source_path -> + replace_media_file(project_id, media_id, source_path) + end), search: one_arg(fn query -> search_media(project_id, query) end), - upsert_translation: three_arg(fn media_id, language, attrs -> upsert_media_translation(project_id, media_id, language, attrs) end) + upsert_translation: + three_arg(fn media_id, language, attrs -> + upsert_media_translation(project_id, media_id, language, attrs) + end) }, scripts: %{ create: one_arg(fn attrs -> create_script(project_id, attrs) end), @@ -123,17 +165,20 @@ defmodule BDS.Scripting.Capabilities do get: one_arg(fn script_id -> load_script(project_id, script_id) end), get_all: zero_or_one_arg(fn _args -> list_scripts(project_id) end), publish: one_arg(fn script_id -> publish_script(project_id, script_id) end), - rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_scripts_from_files(project_id) end) + rebuild_from_files: + zero_or_one_arg(fn _args -> rebuild_scripts_from_files(project_id) end) }, templates: %{ create: one_arg(fn attrs -> create_template(project_id, attrs) end), - update: two_arg(fn template_id, attrs -> update_template(project_id, template_id, attrs) end), + update: + two_arg(fn template_id, attrs -> update_template(project_id, template_id, attrs) end), delete: one_arg(fn template_id -> delete_template(project_id, template_id) end), get: one_arg(fn template_id -> load_template(project_id, template_id) end), get_all: zero_or_one_arg(fn _args -> list_templates(project_id) end), publish: one_arg(fn template_id -> publish_template(project_id, template_id) end), get_enabled_by_kind: one_arg(fn kind -> list_enabled_templates(project_id, kind) end), - rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_templates_from_files(project_id) end), + rebuild_from_files: + zero_or_one_arg(fn _args -> rebuild_templates_from_files(project_id) end), validate: one_arg(fn source -> validate_template_source(source) end) }, tags: %{ @@ -145,7 +190,10 @@ defmodule BDS.Scripting.Capabilities do get_by_name: one_arg(fn tag_name -> load_tag_by_name(project_id, tag_name) end), get_posts_with_tag: one_arg(fn tag_id -> tag_post_ids(project_id, tag_id) end), get_with_counts: zero_or_one_arg(fn _args -> tags_with_counts(project_id) end), - merge: two_arg(fn source_tag_ids, target_tag_id -> merge_tags(project_id, source_tag_ids, target_tag_id) end), + merge: + two_arg(fn source_tag_ids, target_tag_id -> + merge_tags(project_id, source_tag_ids, target_tag_id) + end), rename: two_arg(fn tag_id, new_name -> rename_tag(project_id, tag_id, new_name) end), sync_from_posts: zero_or_one_arg(fn _args -> sync_tags_from_posts(project_id) end) }, @@ -172,23 +220,30 @@ defmodule BDS.Scripting.Capabilities do upload_site: one_arg(fn credentials -> upload_site(project_id, credentials, opts) end) }, chat: %{ - detect_post_language: two_arg(fn title, content -> detect_post_language(title, content, opts) end), + detect_post_language: + two_arg(fn title, content -> detect_post_language(title, content, opts) end), analyze_post: one_arg(fn post_id -> analyze_post(post_id, opts) end), - translate_post: two_arg(fn post_id, language -> translate_post(post_id, language, opts) end), + translate_post: + two_arg(fn post_id, language -> translate_post(post_id, language, opts) end), analyze_media_image: one_arg(fn media_id -> analyze_media_image(media_id, opts) end), - detect_media_language: three_arg(fn title, alt, caption -> detect_media_language(title, alt, caption, opts) end), - translate_media_metadata: two_arg(fn media_id, language -> translate_media_metadata(media_id, language, opts) end) + detect_media_language: + three_arg(fn title, alt, caption -> + detect_media_language(title, alt, caption, opts) + end), + translate_media_metadata: + two_arg(fn media_id, language -> translate_media_metadata(media_id, language, opts) end) }, embeddings: %{ get_progress: zero_or_one_arg(fn _args -> embedding_progress(project_id) end), find_similar: two_arg(fn post_id, limit -> find_similar(post_id, limit) end), - compute_similarities: two_arg(fn post_id, target_ids -> compute_similarities(post_id, target_ids) end), - suggest_tags: two_arg(fn post_id, exclude_tags -> suggest_tags(post_id, exclude_tags) end), + compute_similarities: + two_arg(fn post_id, target_ids -> compute_similarities(post_id, target_ids) end), + suggest_tags: + two_arg(fn post_id, exclude_tags -> suggest_tags(post_id, exclude_tags) end), find_duplicates: zero_or_one_arg(fn _args -> find_duplicates(project_id) end), dismiss_pair: two_arg(fn post_id_a, post_id_b -> dismiss_pair(post_id_a, post_id_b) end), index_unindexed_posts: zero_or_one_arg(fn _args -> index_unindexed_posts(project_id) end) } } end - end diff --git a/lib/bds/scripting/capabilities/app_shell.ex b/lib/bds/scripting/capabilities/app_shell.ex index fd72fbc..6ee29a9 100644 --- a/lib/bds/scripting/capabilities/app_shell.ex +++ b/lib/bds/scripting/capabilities/app_shell.ex @@ -22,9 +22,23 @@ defmodule BDS.Scripting.Capabilities.AppShell do command = string_or_nil(text) case :os.type() do - {:unix, :darwin} -> match?({_output, 0}, System.cmd("pbcopy", [], input: command, stderr_to_stdout: true)) - {:unix, _other} -> match?({_output, 0}, System.cmd("xclip", ["-selection", "clipboard"], input: command, stderr_to_stdout: true)) - {:win32, _other} -> match?({_output, 0}, System.cmd("cmd", ["/c", "clip"], input: command, stderr_to_stdout: true)) + {:unix, :darwin} -> + match?({_output, 0}, System.cmd("pbcopy", [], input: command, stderr_to_stdout: true)) + + {:unix, _other} -> + match?( + {_output, 0}, + System.cmd("xclip", ["-selection", "clipboard"], + input: command, + stderr_to_stdout: true + ) + ) + + {:win32, _other} -> + match?( + {_output, 0}, + System.cmd("cmd", ["/c", "clip"], input: command, stderr_to_stdout: true) + ) end end rescue @@ -94,7 +108,11 @@ defmodule BDS.Scripting.Capabilities.AppShell do end def set_preview_post_target(post_id) do - :persistent_term.put({BDS.Scripting.Capabilities, :preview_post_target}, string_or_nil(post_id)) + :persistent_term.put( + {BDS.Scripting.Capabilities, :preview_post_target}, + string_or_nil(post_id) + ) + true end diff --git a/lib/bds/scripting/capabilities/bridges.ex b/lib/bds/scripting/capabilities/bridges.ex index 6adf2dd..d673097 100644 --- a/lib/bds/scripting/capabilities/bridges.ex +++ b/lib/bds/scripting/capabilities/bridges.ex @@ -98,7 +98,11 @@ defmodule BDS.Scripting.Capabilities.Bridges do end def detect_media_language(title, alt, caption, opts) do - text = Enum.join([string_or_nil(title) || "", string_or_nil(alt) || "", string_or_nil(caption) || ""], "\n") + text = + Enum.join( + [string_or_nil(title) || "", string_or_nil(alt) || "", string_or_nil(caption) || ""], + "\n" + ) case AI.detect_language(text, ai_opts(opts)) do {:ok, %{language_code: language_code}} -> %{"success" => true, "language" => language_code} @@ -125,7 +129,8 @@ defmodule BDS.Scripting.Capabilities.Bridges do # --- Embeddings --- - def embedding_progress(project_id), do: project_id |> Embeddings.get_indexing_progress() |> unwrap_result() + def embedding_progress(project_id), + do: project_id |> Embeddings.get_indexing_progress() |> unwrap_result() def find_similar(post_id, limit) do post_id @@ -148,9 +153,21 @@ defmodule BDS.Scripting.Capabilities.Bridges do |> unwrap_result() end - def find_duplicates(project_id), do: project_id |> Embeddings.find_duplicates() |> unwrap_result() - def dismiss_pair(post_id_a, post_id_b), do: atom_result(Embeddings.dismiss_duplicate_pair(string_or_nil(post_id_a) || "", string_or_nil(post_id_b) || ""), :ok) - def index_unindexed_posts(project_id), do: project_id |> Embeddings.index_unindexed() |> unwrap_result() + def find_duplicates(project_id), + do: project_id |> Embeddings.find_duplicates() |> unwrap_result() + + def dismiss_pair(post_id_a, post_id_b), + do: + atom_result( + Embeddings.dismiss_duplicate_pair( + string_or_nil(post_id_a) || "", + string_or_nil(post_id_b) || "" + ), + :ok + ) + + def index_unindexed_posts(project_id), + do: project_id |> Embeddings.index_unindexed() |> unwrap_result() # --- Opt builders --- diff --git a/lib/bds/scripting/capabilities/crud.ex b/lib/bds/scripting/capabilities/crud.ex index 70cedea..6a11828 100644 --- a/lib/bds/scripting/capabilities/crud.ex +++ b/lib/bds/scripting/capabilities/crud.ex @@ -47,7 +47,10 @@ defmodule BDS.Scripting.Capabilities.Crud do def list_scripts(project_id) do Repo.all( - from(script in Script, where: script.project_id == ^project_id, order_by: [asc: script.created_at]) + from(script in Script, + where: script.project_id == ^project_id, + order_by: [asc: script.created_at] + ) ) |> Enum.map(&sanitize/1) end @@ -68,7 +71,8 @@ defmodule BDS.Scripting.Capabilities.Crud do def fetch_script(project_id, script_id) do Repo.one( from(script in Script, - where: script.project_id == ^project_id and script.id == ^(string_or_nil(script_id) || ""), + where: + script.project_id == ^project_id and script.id == ^(string_or_nil(script_id) || ""), limit: 1 ) ) @@ -86,8 +90,11 @@ defmodule BDS.Scripting.Capabilities.Crud do def update_template(project_id, template_id, attrs) do case fetch_template(project_id, template_id) do - %Template{} -> Templates.update_template(template_id, normalize_map(attrs)) |> unwrap_result() - _other -> nil + %Template{} -> + Templates.update_template(template_id, normalize_map(attrs)) |> unwrap_result() + + _other -> + nil end end @@ -105,7 +112,10 @@ defmodule BDS.Scripting.Capabilities.Crud do def list_templates(project_id) do Repo.all( - from(template in Template, where: template.project_id == ^project_id, order_by: [asc: template.created_at]) + from(template in Template, + where: template.project_id == ^project_id, + order_by: [asc: template.created_at] + ) ) |> Enum.map(&sanitize/1) end @@ -146,7 +156,9 @@ defmodule BDS.Scripting.Capabilities.Crud do def fetch_template(project_id, template_id) do Repo.one( from(template in Template, - where: template.project_id == ^project_id and template.id == ^(string_or_nil(template_id) || ""), + where: + template.project_id == ^project_id and + template.id == ^(string_or_nil(template_id) || ""), limit: 1 ) ) @@ -233,8 +245,14 @@ defmodule BDS.Scripting.Capabilities.Crud do def merge_tags(project_id, source_tag_ids, target_tag_id) do case fetch_tag(project_id, target_tag_id) do - %Tag{} -> atom_result(Tags.merge_tags(normalize_string_list(source_tag_ids), target_tag_id), :merged) - _other -> false + %Tag{} -> + atom_result( + Tags.merge_tags(normalize_string_list(source_tag_ids), target_tag_id), + :merged + ) + + _other -> + false end end diff --git a/lib/bds/scripting/capabilities/media.ex b/lib/bds/scripting/capabilities/media.ex index 9d94572..6c79f44 100644 --- a/lib/bds/scripting/capabilities/media.ex +++ b/lib/bds/scripting/capabilities/media.ex @@ -21,8 +21,12 @@ defmodule BDS.Scripting.Capabilities.Media do def update_media(project_id, media_id, attrs) do case fetch_media(project_id, media_id) do - %MediaRecord{} -> Media.update_media(media_id, attrs |> normalize_map() |> normalize_media_attrs()) |> unwrap_result() - _other -> nil + %MediaRecord{} -> + Media.update_media(media_id, attrs |> normalize_map() |> normalize_media_attrs()) + |> unwrap_result() + + _other -> + nil end end @@ -40,7 +44,10 @@ defmodule BDS.Scripting.Capabilities.Media do def list_media(project_id) do Repo.all( - from(media in MediaRecord, where: media.project_id == ^project_id, order_by: [asc: media.created_at]) + from(media in MediaRecord, + where: media.project_id == ^project_id, + order_by: [asc: media.created_at] + ) ) |> Enum.map(&sanitize/1) end @@ -82,7 +89,11 @@ defmodule BDS.Scripting.Capabilities.Media do def upsert_media_translation(project_id, media_id, language, attrs) do case fetch_media(project_id, media_id) do %MediaRecord{} -> - Media.upsert_media_translation(media_id, string_or_nil(language) || "", normalize_media_translation_attrs(normalize_map(attrs))) + Media.upsert_media_translation( + media_id, + string_or_nil(language) || "", + normalize_media_translation_attrs(normalize_map(attrs)) + ) |> unwrap_result() _other -> @@ -117,7 +128,9 @@ defmodule BDS.Scripting.Capabilities.Media do key = {datetime.year, datetime.month} Map.update(acc, key, 1, &(&1 + 1)) end) - |> Enum.map(fn {{year, month}, count} -> %{"year" => year, "month" => month, "count" => count} end) + |> Enum.map(fn {{year, month}, count} -> + %{"year" => year, "month" => month, "count" => count} + end) |> Enum.sort_by(fn row -> {-row["year"], -row["month"]} end) end @@ -131,7 +144,12 @@ defmodule BDS.Scripting.Capabilities.Media do def media_tags(project_id), do: media_tags_with_counts(project_id) |> Enum.map(& &1["tag"]) def media_tags_with_counts(project_id) do - Repo.all(from(media in MediaRecord, where: media.project_id == ^project_id, order_by: [asc: media.created_at])) + Repo.all( + from(media in MediaRecord, + where: media.project_id == ^project_id, + order_by: [asc: media.created_at] + ) + ) |> Enum.flat_map(&(&1.tags || [])) |> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end) |> Enum.map(fn {tag, count} -> %{"tag" => tag, "count" => count} end) @@ -174,7 +192,9 @@ defmodule BDS.Scripting.Capabilities.Media do case Media.regenerate_thumbnails(media.id) do {:ok, _media} -> Media.thumbnail_paths(media) - |> Enum.map(fn {size, relative_path} -> {to_string(size), Path.join(project_path(project_id), relative_path)} end) + |> Enum.map(fn {size, relative_path} -> + {to_string(size), Path.join(project_path(project_id), relative_path)} + end) |> Map.new() {:error, _reason} -> @@ -227,14 +247,40 @@ defmodule BDS.Scripting.Capabilities.Media do tags = Map.get(media, "tags", []) language = Map.get(media, "language") - matches_year = compare_optional(Map.get(filters, "year"), fn year -> created_at.year == integer_or_default(year, created_at.year) end) - matches_month = compare_optional(Map.get(filters, "month"), fn month -> created_at.month == integer_or_default(month, created_at.month) end) - matches_language = compare_optional(blank_to_nil(Map.get(filters, "language")), fn value -> language == value end) - matches_tags = compare_optional(Map.get(filters, "tags"), fn required_tags -> Enum.all?(normalize_string_list(required_tags), &(&1 in tags)) end) - matches_from = compare_optional(parse_datetime(Map.get(filters, "from") || Map.get(filters, "start_date")), fn from_dt -> DateTime.compare(created_at, from_dt) != :lt end) - matches_to = compare_optional(parse_datetime(Map.get(filters, "to") || Map.get(filters, "end_date")), fn to_dt -> DateTime.compare(created_at, to_dt) != :gt end) + matches_year = + compare_optional(Map.get(filters, "year"), fn year -> + created_at.year == integer_or_default(year, created_at.year) + end) - matches_year and matches_month and matches_language and matches_tags and matches_from and matches_to + matches_month = + compare_optional(Map.get(filters, "month"), fn month -> + created_at.month == integer_or_default(month, created_at.month) + end) + + matches_language = + compare_optional(blank_to_nil(Map.get(filters, "language")), fn value -> + language == value + end) + + matches_tags = + compare_optional(Map.get(filters, "tags"), fn required_tags -> + Enum.all?(normalize_string_list(required_tags), &(&1 in tags)) + end) + + matches_from = + compare_optional( + parse_datetime(Map.get(filters, "from") || Map.get(filters, "start_date")), + fn from_dt -> DateTime.compare(created_at, from_dt) != :lt end + ) + + matches_to = + compare_optional( + parse_datetime(Map.get(filters, "to") || Map.get(filters, "end_date")), + fn to_dt -> DateTime.compare(created_at, to_dt) != :gt end + ) + + matches_year and matches_month and matches_language and matches_tags and matches_from and + matches_to end def media_datetime(media) do @@ -247,8 +293,11 @@ defmodule BDS.Scripting.Capabilities.Media do _other -> DateTime.utc_now() end - value when is_integer(value) -> DateTime.from_unix!(value, :millisecond) - _other -> DateTime.utc_now() + value when is_integer(value) -> + DateTime.from_unix!(value, :millisecond) + + _other -> + DateTime.utc_now() end end end diff --git a/lib/bds/scripting/capabilities/posts.ex b/lib/bds/scripting/capabilities/posts.ex index d163ffe..20667a3 100644 --- a/lib/bds/scripting/capabilities/posts.ex +++ b/lib/bds/scripting/capabilities/posts.ex @@ -22,8 +22,11 @@ defmodule BDS.Scripting.Capabilities.Posts do def update_post(project_id, post_id, attrs) do case fetch_post(project_id, post_id) do - %Post{} -> Posts.update_post(post_id, normalize_map(attrs)) |> unwrap_result(&post_payload/1) - _other -> nil + %Post{} -> + Posts.update_post(post_id, normalize_map(attrs)) |> unwrap_result(&post_payload/1) + + _other -> + nil end end @@ -42,7 +45,9 @@ defmodule BDS.Scripting.Capabilities.Posts do end def list_posts(project_id) do - Repo.all(from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at])) + Repo.all( + from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at]) + ) |> Enum.map(&post_payload/1) end @@ -80,13 +85,19 @@ defmodule BDS.Scripting.Capabilities.Posts do end def generate_unique_post_slug(project_id, title, exclude_post_id) do - Posts.unique_slug_for_title(project_id, string_or_nil(title) || "", string_or_nil(exclude_post_id)) + Posts.unique_slug_for_title( + project_id, + string_or_nil(title) || "", + string_or_nil(exclude_post_id) + ) end def posts_by_status(project_id, status) do normalized_status = string_or_nil(status) || "" - Repo.all(from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at])) + Repo.all( + from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at]) + ) |> Enum.filter(&(to_string(&1.status) == normalized_status)) |> Enum.map(&post_payload/1) end @@ -143,8 +154,11 @@ defmodule BDS.Scripting.Capabilities.Posts do def publish_post_translation(project_id, post_id, language) do case fetch_post(project_id, post_id) do - %Post{} -> Posts.publish_post_translation(post_id, string_or_nil(language) || "") |> unwrap_result() - _other -> nil + %Post{} -> + Posts.publish_post_translation(post_id, string_or_nil(language) || "") |> unwrap_result() + + _other -> + nil end end @@ -174,7 +188,10 @@ defmodule BDS.Scripting.Capabilities.Posts do def post_tags(project_id), do: names_with_counts(project_id, :tags) |> Enum.map(& &1["name"]) def post_tags_with_counts(project_id), do: names_with_counts(project_id, :tags) - def post_categories(project_id), do: names_with_counts(project_id, :categories) |> Enum.map(& &1["name"]) + + def post_categories(project_id), + do: names_with_counts(project_id, :categories) |> Enum.map(& &1["name"]) + def post_categories_with_counts(project_id), do: names_with_counts(project_id, :categories) def list_post_translations(project_id, post_id) do @@ -209,9 +226,14 @@ defmodule BDS.Scripting.Capabilities.Posts do def has_published_post_version(project_id, post_id) do case fetch_post(project_id, post_id) do - %Post{status: :published} -> true - %Post{published_at: published_at, file_path: file_path} -> not is_nil(published_at) or file_path not in [nil, ""] - _other -> false + %Post{status: :published} -> + true + + %Post{published_at: published_at, file_path: file_path} -> + not is_nil(published_at) or file_path not in [nil, ""] + + _other -> + false end end diff --git a/lib/bds/scripting/capabilities/projects.ex b/lib/bds/scripting/capabilities/projects.ex index d405087..1ab8e5d 100644 --- a/lib/bds/scripting/capabilities/projects.ex +++ b/lib/bds/scripting/capabilities/projects.ex @@ -10,9 +10,11 @@ defmodule BDS.Scripting.Capabilities.Projects do alias BDS.Repo alias BDS.Tags - def create_project(attrs), do: attrs |> normalize_map() |> ProjectsCtx.create_project() |> unwrap_result() + def create_project(attrs), + do: attrs |> normalize_map() |> ProjectsCtx.create_project() |> unwrap_result() - def delete_project(project_id), do: boolean_result(ProjectsCtx.delete_project(string_or_nil(project_id))) + def delete_project(project_id), + do: boolean_result(ProjectsCtx.delete_project(string_or_nil(project_id))) def delete_project_with_data(project_id) do case string_or_nil(project_id) && ProjectsCtx.get_project(string_or_nil(project_id)) do @@ -113,8 +115,12 @@ defmodule BDS.Scripting.Capabilities.Projects do normalized_name = string_or_nil(name) |> to_string() |> String.trim() cond do - normalized_name == "" -> metadata_tags(project_id) - load_tag_by_name(project_id, normalized_name) -> metadata_tags(project_id) + normalized_name == "" -> + metadata_tags(project_id) + + load_tag_by_name(project_id, normalized_name) -> + metadata_tags(project_id) + true -> create_tag(project_id, %{"name" => normalized_name}) metadata_tags(project_id) diff --git a/lib/bds/scripting/capabilities/util.ex b/lib/bds/scripting/capabilities/util.ex index dd41c7a..f7e12c3 100644 --- a/lib/bds/scripting/capabilities/util.ex +++ b/lib/bds/scripting/capabilities/util.ex @@ -46,7 +46,13 @@ defmodule BDS.Scripting.Capabilities.Util do end def normalize_input(list) when is_list(list) do - if Enum.all?(list, &match?({key, _value} when is_integer(key) or is_float(key) or is_binary(key) or is_atom(key), &1)) do + if Enum.all?( + list, + &match?( + {key, _value} when is_integer(key) or is_float(key) or is_binary(key) or is_atom(key), + &1 + ) + ) do normalized = Map.new(list, fn {key, value} -> {normalize_input_key(key), normalize_input(value)} end) diff --git a/lib/bds/scripting/lua.ex b/lib/bds/scripting/lua.ex index 26a2dc2..d77fe60 100644 --- a/lib/bds/scripting/lua.ex +++ b/lib/bds/scripting/lua.ex @@ -85,7 +85,8 @@ defmodule BDS.Scripting.Lua do defp install_capability(path, value, state) when is_map(value) do with {:ok, seeded_state} <- set_capability(path, %{}, state) do - Enum.reduce_while(value, {:ok, seeded_state}, fn {name, nested_value}, {:ok, current_state} -> + Enum.reduce_while(value, {:ok, seeded_state}, fn {name, nested_value}, + {:ok, current_state} -> case install_capability(path ++ [to_string(name)], nested_value, current_state) do {:ok, next_state} -> {:cont, {:ok, next_state}} {:error, reason} -> {:halt, {:error, reason}} @@ -142,7 +143,10 @@ defmodule BDS.Scripting.Lua do defp sandbox_run(script, flags, state), do: apply(:luerl_sandbox, :run, [script, flags, state]) defp normalize_sandbox_result({:ok, result, next_state}), do: {:ok, result, next_state} - defp normalize_sandbox_result({:error, {:reductions, count}}), do: {:error, {:reductions_exceeded, count}} + + defp normalize_sandbox_result({:error, {:reductions, count}}), + do: {:error, {:reductions_exceeded, count}} + defp normalize_sandbox_result({:error, :timeout}), do: {:error, :timeout} defp normalize_sandbox_result({:error, reason}), do: {:error, reason} diff --git a/lib/bds/scripts.ex b/lib/bds/scripts.ex index 6f8c0eb..be3cac8 100644 --- a/lib/bds/scripts.ex +++ b/lib/bds/scripts.ex @@ -13,6 +13,10 @@ defmodule BDS.Scripts do alias BDS.Scripts.Script alias BDS.Slug + @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} + @type script_result :: {:ok, Script.t()} | {:error, Ecto.Changeset.t() | term()} + + @spec create_script(attrs()) :: {:ok, Script.t()} | {:error, Ecto.Changeset.t()} def create_script(attrs) do now = Persistence.now_ms() project_id = attr(attrs, :project_id) @@ -41,6 +45,7 @@ defmodule BDS.Scripts do @spec get_script(String.t()) :: Script.t() | nil def get_script(script_id), do: Repo.get(Script, script_id) + @spec publish_script(String.t()) :: script_result() | {:error, :not_found} def publish_script(script_id) do case Repo.get(Script, script_id) do nil -> @@ -72,6 +77,7 @@ defmodule BDS.Scripts do end end + @spec update_script(String.t(), attrs()) :: script_result() | {:error, :not_found} def update_script(script_id, attrs) do case Repo.get(Script, script_id) do nil -> @@ -109,6 +115,7 @@ defmodule BDS.Scripts do end end + @spec delete_script(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()} def delete_script(script_id) do case Repo.get(Script, script_id) do nil -> @@ -121,6 +128,7 @@ defmodule BDS.Scripts do end end + @spec rebuild_scripts_from_files(String.t(), keyword()) :: {:ok, [Script.t()]} def rebuild_scripts_from_files(project_id, opts \\ []) do project = Projects.get_project!(project_id) @@ -146,6 +154,7 @@ defmodule BDS.Scripts do {:ok, scripts} end + @spec sync_script_from_file(String.t()) :: {:ok, Script.t()} | {:error, :not_found} def sync_script_from_file(script_id) do case Repo.get(Script, script_id) do nil -> @@ -166,6 +175,7 @@ defmodule BDS.Scripts do end end + @spec sync_published_script_file(String.t()) :: {:ok, Script.t()} | {:error, :not_found} def sync_published_script_file(script_id) do case Repo.get(Script, script_id) do nil -> @@ -183,6 +193,8 @@ defmodule BDS.Scripts do end end + @spec import_orphan_script_file(String.t(), String.t()) :: + {:ok, Script.t()} | {:error, :not_found} def import_orphan_script_file(project_id, relative_path) do project = Projects.get_project!(project_id) full_path = Path.join(Projects.project_data_dir(project), relative_path) diff --git a/lib/bds/settings.ex b/lib/bds/settings.ex index 959e1d3..6f81777 100644 --- a/lib/bds/settings.ex +++ b/lib/bds/settings.ex @@ -18,7 +18,11 @@ defmodule BDS.Settings do setting = Repo.get(Setting, key) || %Setting{} setting - |> Setting.changeset(%{key: key, value: to_string(value || ""), updated_at: Persistence.now_ms()}) + |> Setting.changeset(%{ + key: key, + value: to_string(value || ""), + updated_at: Persistence.now_ms() + }) |> Repo.insert_or_update() |> case do {:ok, _setting} -> :ok diff --git a/lib/bds/tags.ex b/lib/bds/tags.ex index dbf094c..792e486 100644 --- a/lib/bds/tags.ex +++ b/lib/bds/tags.ex @@ -10,6 +10,11 @@ defmodule BDS.Tags do alias BDS.Repo alias BDS.Tags.Tag + @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} + @type tag_result :: {:ok, Tag.t()} | {:error, Ecto.Changeset.t() | term()} + @type action_result :: {:ok, :deleted | :merged} | {:error, :not_found | term()} + + @spec create_tag(attrs()) :: tag_result() def create_tag(attrs) do project_id = attr(attrs, :project_id) name = attr(attrs, :name) |> to_string() |> String.trim() @@ -40,15 +45,18 @@ defmodule BDS.Tags do end end + @spec list_tags(String.t()) :: [Tag.t()] def list_tags(project_id) do Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name]) end + @spec sync_tags_json(String.t()) :: :ok def sync_tags_json(project_id) do write_tags_json(project_id) :ok end + @spec sync_tags_from_posts(String.t()) :: {:ok, [Tag.t()]} | {:error, term()} def sync_tags_from_posts(project_id) do Repo.transaction(fn -> existing_names = @@ -94,10 +102,12 @@ defmodule BDS.Tags do {:ok, tags} end - {:error, reason} -> {:error, reason} + {:error, reason} -> + {:error, reason} end end + @spec update_tag(String.t(), attrs()) :: tag_result() | {:error, :not_found} def update_tag(tag_id, attrs) do case Repo.get(Tag, tag_id) do nil -> @@ -125,6 +135,7 @@ defmodule BDS.Tags do end end + @spec delete_tag(String.t()) :: action_result() def delete_tag(tag_id) do case Repo.get(Tag, tag_id) do nil -> @@ -149,11 +160,13 @@ defmodule BDS.Tags do {:ok, :deleted} end - {:error, reason} -> {:error, reason} + {:error, reason} -> + {:error, reason} end end end + @spec rename_tag(String.t(), String.t()) :: tag_result() | {:error, :not_found} def rename_tag(tag_id, new_name) do case Repo.get(Tag, tag_id) do nil -> @@ -187,12 +200,14 @@ defmodule BDS.Tags do {:ok, updated_tag} end - {:error, reason} -> {:error, reason} + {:error, reason} -> + {:error, reason} end end end end + @spec merge_tags([String.t()], String.t()) :: action_result() def merge_tags(source_tag_ids, target_tag_id) do case Repo.get(Tag, target_tag_id) do nil -> @@ -224,7 +239,8 @@ defmodule BDS.Tags do {:ok, :merged} end - {:error, reason} -> {:error, reason} + {:error, reason} -> + {:error, reason} end end end diff --git a/lib/bds/tags/tag.ex b/lib/bds/tags/tag.ex index d382e51..48b0ec5 100644 --- a/lib/bds/tags/tag.ex +++ b/lib/bds/tags/tag.ex @@ -7,6 +7,17 @@ defmodule BDS.Tags.Tag do @primary_key {:id, :string, autogenerate: false} @foreign_key_type :string + @type t :: %__MODULE__{ + id: String.t() | nil, + project_id: String.t() | nil, + name: String.t() | nil, + color: String.t() | nil, + post_template_slug: String.t() | nil, + created_at: integer() | nil, + updated_at: integer() | nil, + project: term() + } + schema "tags" do field :name, :string field :color, :string @@ -17,6 +28,7 @@ defmodule BDS.Tags.Tag do belongs_to :project, BDS.Projects.Project, type: :string end + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(tag, attrs) do tag |> cast( diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex index 72191af..c457d07 100644 --- a/lib/bds/templates.ex +++ b/lib/bds/templates.ex @@ -15,6 +15,10 @@ defmodule BDS.Templates do alias BDS.Tags alias BDS.Templates.Template + @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} + @type template_result :: {:ok, Template.t()} | {:error, Ecto.Changeset.t() | term()} + + @spec create_template(attrs()) :: {:ok, Template.t()} | {:error, Ecto.Changeset.t()} def create_template(attrs) do now = Persistence.now_ms() project_id = attr(attrs, :project_id) @@ -41,6 +45,7 @@ defmodule BDS.Templates do @spec get_template(String.t()) :: Template.t() | nil def get_template(template_id), do: Repo.get(Template, template_id) + @spec publish_template(String.t()) :: template_result() | {:error, :not_found} def publish_template(template_id) do case Repo.get(Template, template_id) do nil -> @@ -72,6 +77,7 @@ defmodule BDS.Templates do end end + @spec update_template(String.t(), attrs()) :: template_result() | {:error, :not_found} def update_template(template_id, attrs) do case Repo.get(Template, template_id) do nil -> @@ -151,6 +157,7 @@ defmodule BDS.Templates do end end + @spec rebuild_templates_from_files(String.t(), keyword()) :: {:ok, [Template.t()]} def rebuild_templates_from_files(project_id, opts \\ []) do project = Projects.get_project!(project_id) @@ -178,6 +185,8 @@ defmodule BDS.Templates do {:ok, templates} end + @spec delete_template(String.t(), keyword()) :: + {:ok, :deleted} | {:error, :not_found | {:has_references, map()} | term()} def delete_template(template_id, opts \\ []) do case Repo.get(Template, template_id) do nil -> @@ -204,6 +213,7 @@ defmodule BDS.Templates do end end + @spec sync_template_from_file(String.t()) :: {:ok, Template.t()} | {:error, :not_found} def sync_template_from_file(template_id) do case Repo.get(Template, template_id) do nil -> @@ -224,6 +234,7 @@ defmodule BDS.Templates do end end + @spec sync_published_template_file(String.t()) :: {:ok, Template.t()} | {:error, :not_found} def sync_published_template_file(template_id) do case Repo.get(Template, template_id) do nil -> @@ -246,6 +257,8 @@ defmodule BDS.Templates do end end + @spec import_orphan_template_file(String.t(), String.t()) :: + {:ok, Template.t()} | {:error, :not_found} def import_orphan_template_file(project_id, relative_path) do project = Projects.get_project!(project_id) full_path = Path.join(Projects.project_data_dir(project), relative_path) diff --git a/lib/bds/ui/dashboard.ex b/lib/bds/ui/dashboard.ex index 22ab911..a9d2547 100644 --- a/lib/bds/ui/dashboard.ex +++ b/lib/bds/ui/dashboard.ex @@ -75,11 +75,15 @@ defmodule BDS.UI.Dashboard do end defp post_stats(posts) do - Enum.reduce(posts, %{total_posts: 0, draft_count: 0, published_count: 0, archived_count: 0}, fn post, acc -> - acc - |> Map.update!(:total_posts, &(&1 + 1)) - |> increment_status(post.status) - end) + Enum.reduce( + posts, + %{total_posts: 0, draft_count: 0, published_count: 0, archived_count: 0}, + fn post, acc -> + acc + |> Map.update!(:total_posts, &(&1 + 1)) + |> increment_status(post.status) + end + ) end defp media_stats(media_items) do @@ -116,7 +120,9 @@ defmodule BDS.UI.Dashboard do |> Enum.flat_map(&normalize_terms(&1.categories)) |> Enum.frequencies() |> Enum.map(fn {category, count} -> %{category: category, count: count} end) - |> Enum.sort_by(fn %{category: category, count: count} -> {-count, String.downcase(category)} end) + |> Enum.sort_by(fn %{category: category, count: count} -> + {-count, String.downcase(category)} + end) end defp recent_posts(posts) do @@ -151,7 +157,9 @@ defmodule BDS.UI.Dashboard do defp increment_status(counts, _status), do: counts defp maybe_increment_image_count(counts, mime_type) when is_binary(mime_type) do - if String.starts_with?(mime_type, "image/"), do: Map.update!(counts, :image_count, &(&1 + 1)), else: counts + if String.starts_with?(mime_type, "image/"), + do: Map.update!(counts, :image_count, &(&1 + 1)), + else: counts end defp maybe_increment_image_count(counts, _mime_type), do: counts diff --git a/lib/bds/ui/registry.ex b/lib/bds/ui/registry.ex index be63f11..2d1e83f 100644 --- a/lib/bds/ui/registry.ex +++ b/lib/bds/ui/registry.ex @@ -2,16 +2,86 @@ defmodule BDS.UI.Registry do @moduledoc false @sidebar_views [ - %{id: :posts, label: "Posts", activity_group: :top, editor_route: :post, entity_tab: true, demo_kind: :entity}, - %{id: :pages, label: "Pages", activity_group: :top, editor_route: :post, entity_tab: true, demo_kind: :entity}, - %{id: :media, label: "Media", activity_group: :top, editor_route: :media, entity_tab: true, demo_kind: :entity}, - %{id: :scripts, label: "Scripts", activity_group: :top, editor_route: :scripts, entity_tab: true, demo_kind: :entity}, - %{id: :templates, label: "Templates", activity_group: :top, editor_route: :templates, entity_tab: true, demo_kind: :entity}, - %{id: :tags, label: "Tags", activity_group: :top, editor_route: :tags, singleton: true, demo_kind: :singleton}, - %{id: :chat, label: "AI Assistant", activity_group: :top, editor_route: :chat, entity_tab: true, demo_kind: :entity}, - %{id: :import, label: "Import", activity_group: :top, editor_route: :import, entity_tab: true, demo_kind: :entity}, - %{id: :git, label: "Source Control", activity_group: :bottom, editor_route: :git_diff, entity_tab: true, demo_kind: :entity}, - %{id: :settings, label: "Settings", activity_group: :bottom, editor_route: :settings, singleton: true, demo_kind: :singleton} + %{ + id: :posts, + label: "Posts", + activity_group: :top, + editor_route: :post, + entity_tab: true, + demo_kind: :entity + }, + %{ + id: :pages, + label: "Pages", + activity_group: :top, + editor_route: :post, + entity_tab: true, + demo_kind: :entity + }, + %{ + id: :media, + label: "Media", + activity_group: :top, + editor_route: :media, + entity_tab: true, + demo_kind: :entity + }, + %{ + id: :scripts, + label: "Scripts", + activity_group: :top, + editor_route: :scripts, + entity_tab: true, + demo_kind: :entity + }, + %{ + id: :templates, + label: "Templates", + activity_group: :top, + editor_route: :templates, + entity_tab: true, + demo_kind: :entity + }, + %{ + id: :tags, + label: "Tags", + activity_group: :top, + editor_route: :tags, + singleton: true, + demo_kind: :singleton + }, + %{ + id: :chat, + label: "AI Assistant", + activity_group: :top, + editor_route: :chat, + entity_tab: true, + demo_kind: :entity + }, + %{ + id: :import, + label: "Import", + activity_group: :top, + editor_route: :import, + entity_tab: true, + demo_kind: :entity + }, + %{ + id: :git, + label: "Source Control", + activity_group: :bottom, + editor_route: :git_diff, + entity_tab: true, + demo_kind: :entity + }, + %{ + id: :settings, + label: "Settings", + activity_group: :bottom, + editor_route: :settings, + singleton: true, + demo_kind: :singleton + } ] @editor_routes [ @@ -41,4 +111,4 @@ defmodule BDS.UI.Registry do def sidebar_view(id) when is_atom(id), do: Enum.find(@sidebar_views, &(&1.id == id)) def editor_route(id) when is_atom(id), do: Enum.find(@editor_routes, &(&1.id == id)) -end \ No newline at end of file +end diff --git a/lib/bds/ui/workbench.ex b/lib/bds/ui/workbench.ex index c97026d..c55c576 100644 --- a/lib/bds/ui/workbench.ex +++ b/lib/bds/ui/workbench.ex @@ -225,6 +225,7 @@ defmodule BDS.UI.Workbench do end defp transient_tab?(type, _intent) when type in [:chat, :import], do: false + defp transient_tab?(type, intent) do if MapSet.member?(@singleton_tabs, type), do: false, else: transient_from_intent(intent) end diff --git a/lib/bds/wxr_parser.ex b/lib/bds/wxr_parser.ex index 46b7322..687d989 100644 --- a/lib/bds/wxr_parser.ex +++ b/lib/bds/wxr_parser.ex @@ -4,7 +4,12 @@ defmodule BDS.WxrParser do require Record Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl")) - Record.defrecord(:xmlAttribute, Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl")) + + Record.defrecord( + :xmlAttribute, + Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl") + ) + Record.defrecord(:xmlText, Record.extract(:xmlText, from_lib: "xmerl/include/xmerl.hrl")) def parse_file(file_path) when is_binary(file_path) do @@ -180,7 +185,8 @@ defmodule BDS.WxrParser do child when is_tuple(child) and tuple_size(child) > 0 and elem(child, 0) == :xmlElement -> text_content(child) - _other -> "" + _other -> + "" end) |> String.trim() end diff --git a/lib/mix/tasks/bds.package.ex b/lib/mix/tasks/bds.package.ex index ec6ae8b..88147ff 100644 --- a/lib/mix/tasks/bds.package.ex +++ b/lib/mix/tasks/bds.package.ex @@ -20,8 +20,10 @@ defmodule Mix.Tasks.Bds.Package do platform: platform, version: version, output_dir: opts[:output] || Path.expand("dist/#{platform}", File.cwd!()), - app_release_source: opts[:app_release] || Path.expand("_build/#{env_name}/rel/bds", File.cwd!()), - mcp_release_source: opts[:mcp_release] || Path.expand("_build/#{env_name}/rel/bds_mcp", File.cwd!()) + app_release_source: + opts[:app_release] || Path.expand("_build/#{env_name}/rel/bds", File.cwd!()), + mcp_release_source: + opts[:mcp_release] || Path.expand("_build/#{env_name}/rel/bds_mcp", File.cwd!()) ] case BDS.ReleasePackaging.package(package_opts) do diff --git a/test/bds/ai_test.exs b/test/bds/ai_test.exs index edb49d6..5fd2065 100644 --- a/test/bds/ai_test.exs +++ b/test/bds/ai_test.exs @@ -197,11 +197,13 @@ defmodule BDS.AITest do test "put_endpoint, get_endpoint, and delete_endpoint manage encrypted endpoint settings" do assert {:ok, endpoint} = - BDS.AI.put_endpoint(:online, %{ - url: "https://api.example.test/v1", - api_key: "top-secret", - model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + BDS.AI.put_endpoint( + :online, + %{ + url: "https://api.example.test/v1", + api_key: "top-secret", + model: "gpt-4o-mini" + }, secret_backend: FakeSecretBackend) assert endpoint.kind == :online assert endpoint.url == "https://api.example.test/v1" @@ -263,21 +265,26 @@ defmodule BDS.AITest do assert {:ok, result} = BDS.AI.refresh_model_catalog(http_client: http_client) assert result.not_modified == true - assert_received {:conditional_headers, %{"accept" => "application/json", "if-none-match" => "W/\"catalog-v1\""}} + + assert_received {:conditional_headers, + %{"accept" => "application/json", "if-none-match" => "W/\"catalog-v1\""}} end test "list_endpoint_models reads openai-compatible models from the configured endpoint" do assert {:ok, models} = - BDS.AI.list_endpoint_models(%{url: "https://api.example.test/v1", api_key: "online-secret"}, + BDS.AI.list_endpoint_models( + %{url: "https://api.example.test/v1", api_key: "online-secret"}, http_client: FakeEndpointHttpClient ) - assert [%{id: "gpt-4.1", label: "gpt-4.1"}, %{id: "gpt-4.1-mini", label: "gpt-4.1-mini"}] = models + assert [%{id: "gpt-4.1", label: "gpt-4.1"}, %{id: "gpt-4.1-mini", label: "gpt-4.1-mini"}] = + models end test "list_endpoint_models returns an error for malformed endpoint JSON" do assert {:error, %{kind: :invalid_json_response, reason: %Jason.DecodeError{}}} = - BDS.AI.list_endpoint_models(%{url: "https://api.example.test/v1", api_key: "online-secret"}, + BDS.AI.list_endpoint_models( + %{url: "https://api.example.test/v1", api_key: "online-secret"}, http_client: BadJsonEndpointHttpClient ) end @@ -303,18 +310,22 @@ defmodule BDS.AITest do test "airplane mode routes title tasks to airplane endpoint and offline title model" do assert {:ok, _endpoint} = - BDS.AI.put_endpoint(:online, %{ - url: "https://api.example.test/v1", - api_key: "online-secret", - model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + BDS.AI.put_endpoint( + :online, + %{ + url: "https://api.example.test/v1", + api_key: "online-secret", + model: "gpt-4o-mini" + }, secret_backend: FakeSecretBackend) assert {:ok, _endpoint} = - BDS.AI.put_endpoint(:airplane, %{ - url: "http://localhost:11434/v1", - api_key: nil, - model: "llama-default" - }, secret_backend: FakeSecretBackend) + BDS.AI.put_endpoint( + :airplane, + %{ + url: "http://localhost:11434/v1", + api_key: nil, + model: "llama-default" + }, secret_backend: FakeSecretBackend) assert :ok = BDS.AI.set_airplane_mode(true) assert :ok = BDS.AI.put_model_preference(:airplane_title, "llama3.1") @@ -337,18 +348,24 @@ defmodule BDS.AITest do test "translate_post uses the online title model when airplane mode is disabled" do assert {:ok, _endpoint} = - BDS.AI.put_endpoint(:online, %{ - url: "https://api.example.test/v1", - api_key: "online-secret", - model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + BDS.AI.put_endpoint( + :online, + %{ + url: "https://api.example.test/v1", + api_key: "online-secret", + model: "gpt-4o-mini" + }, secret_backend: FakeSecretBackend) assert :ok = BDS.AI.set_airplane_mode(false) assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini") assert {:ok, translation} = BDS.AI.translate_post( - %{title: "Hello World", excerpt: "Short summary", content: "# Hello\n\nSource body"}, + %{ + title: "Hello World", + excerpt: "Short summary", + content: "# Hello\n\nSource body" + }, "de", runtime: FakeRuntime, test_pid: self(), @@ -366,11 +383,13 @@ defmodule BDS.AITest do test "analyze_import_taxonomy uses the selected model override and returns only valid existing-term mappings" do assert {:ok, _endpoint} = - BDS.AI.put_endpoint(:online, %{ - url: "https://api.example.test/v1", - api_key: "online-secret", - model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + BDS.AI.put_endpoint( + :online, + %{ + url: "https://api.example.test/v1", + api_key: "online-secret", + model: "gpt-4o-mini" + }, secret_backend: FakeSecretBackend) assert :ok = BDS.AI.set_airplane_mode(false) assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini") @@ -396,23 +415,26 @@ defmodule BDS.AITest do test "analyze_image requires a vision-capable airplane model before sending image input" do assert {:ok, _endpoint} = - BDS.AI.put_endpoint(:airplane, %{ - url: "http://localhost:11434/v1", - api_key: nil, - model: "llama-default" - }, secret_backend: FakeSecretBackend) + BDS.AI.put_endpoint( + :airplane, + %{ + url: "http://localhost:11434/v1", + api_key: nil, + model: "llama-default" + }, secret_backend: FakeSecretBackend) assert :ok = BDS.AI.set_airplane_mode(true) assert :ok = BDS.AI.put_model_preference(:airplane_image_analysis, "llama3.2") assert {:error, %{kind: :model_capability_missing}} = - BDS.AI.analyze_image(%{ - mime_type: "image/png", - title: "Source", - alt: nil, - caption: nil, - image_url: "file:///tmp/test.png" - }, runtime: FakeRuntime, test_pid: self(), secret_backend: FakeSecretBackend) + BDS.AI.analyze_image( + %{ + mime_type: "image/png", + title: "Source", + alt: nil, + caption: nil, + image_url: "file:///tmp/test.png" + }, runtime: FakeRuntime, test_pid: self(), secret_backend: FakeSecretBackend) assert :ok = BDS.AI.put_model_capabilities("llama3.2", %{ @@ -421,13 +443,14 @@ defmodule BDS.AITest do }) assert {:ok, analysis} = - BDS.AI.analyze_image(%{ - mime_type: "image/png", - title: "Source", - alt: nil, - caption: nil, - image_url: "file:///tmp/test.png" - }, runtime: FakeRuntime, test_pid: self(), secret_backend: FakeSecretBackend) + BDS.AI.analyze_image( + %{ + mime_type: "image/png", + title: "Source", + alt: nil, + caption: nil, + image_url: "file:///tmp/test.png" + }, runtime: FakeRuntime, test_pid: self(), secret_backend: FakeSecretBackend) assert analysis.alt == "Orange sunset over calm water" @@ -442,11 +465,13 @@ defmodule BDS.AITest do :ok = seed_project_content(project.id) assert {:ok, _endpoint} = - BDS.AI.put_endpoint(:online, %{ - url: "https://api.example.test/v1", - api_key: "online-secret", - model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + BDS.AI.put_endpoint( + :online, + %{ + url: "https://api.example.test/v1", + api_key: "online-secret", + model: "gpt-4o-mini" + }, secret_backend: FakeSecretBackend) assert :ok = BDS.AI.set_airplane_mode(false) assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"}) @@ -462,13 +487,13 @@ defmodule BDS.AITest do assert reply.assistant_message.content == "You currently have 1 post and 1 media item." messages = BDS.AI.list_chat_messages(conversation.id) - assert Enum.map(messages, & &1.role) == [:user, :assistant, :tool, :assistant] + assert Enum.map(messages, & &1.role) == [:user, :assistant, :tool, :assistant] - assistant_tool_call = Enum.at(messages, 1) - tool_message = Enum.at(messages, 2) - assistant_message = Enum.at(messages, 3) + assistant_tool_call = Enum.at(messages, 1) + tool_message = Enum.at(messages, 2) + assistant_message = Enum.at(messages, 3) - assert [%{"id" => "call-blog-stats", "name" => "blog_stats"}] = assistant_tool_call.tool_calls + assert [%{"id" => "call-blog-stats", "name" => "blog_stats"}] = assistant_tool_call.tool_calls assert tool_message.tool_call_id == "call-blog-stats" assert tool_message.content =~ "post_count" assert assistant_message.token_usage_input == 64 @@ -489,11 +514,13 @@ defmodule BDS.AITest do test "cancel_chat aborts an in-flight chat turn" do assert {:ok, _endpoint} = - BDS.AI.put_endpoint(:online, %{ - url: "https://api.example.test/v1", - api_key: "online-secret", - model: "gpt-4o-mini" - }, secret_backend: FakeSecretBackend) + BDS.AI.put_endpoint( + :online, + %{ + url: "https://api.example.test/v1", + api_key: "online-secret", + model: "gpt-4o-mini" + }, secret_backend: FakeSecretBackend) assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"}) diff --git a/test/bds/cli_sync_test.exs b/test/bds/cli_sync_test.exs index ae159df..8d770da 100644 --- a/test/bds/cli_sync_test.exs +++ b/test/bds/cli_sync_test.exs @@ -38,7 +38,8 @@ defmodule BDS.CliSyncTest do :ok = Watcher.poll_now(watcher) - assert_receive {:entity_changed, %{entity: "post", entity_id: "post-1", action: :updated}}, 500 + assert_receive {:entity_changed, %{entity: "post", entity_id: "post-1", action: :updated}}, + 500 seen_notification = Repo.get!(BDS.CliSync.Notification, notification.id) assert is_integer(seen_notification.seen_at) @@ -76,7 +77,9 @@ defmodule BDS.CliSyncTest do assert {:ok, %{processed: 1, unprocessed: 1}} = CliSync.prune_notifications(now) - remaining_ids = Repo.all(from notification in BDS.CliSync.Notification, select: notification.entity_id) + remaining_ids = + Repo.all(from notification in BDS.CliSync.Notification, select: notification.entity_id) + assert remaining_ids == ["fresh"] end end diff --git a/test/bds/desktop/automation_test.exs b/test/bds/desktop/automation_test.exs index ab70b80..4af056b 100644 --- a/test/bds/desktop/automation_test.exs +++ b/test/bds/desktop/automation_test.exs @@ -25,6 +25,7 @@ defmodule BDS.Desktop.AutomationTest do assert snapshot.assistant_visible == false assert snapshot.panel_visible == false assert snapshot.editor_title == "Dashboard" + assert snapshot.activity_labels == [ "Posts", "Pages", @@ -37,6 +38,7 @@ defmodule BDS.Desktop.AutomationTest do "Git", "Settings" ] + assert "Drafts" in snapshot.sidebar_sections assert "Status" in snapshot.editor_meta_labels @@ -155,7 +157,10 @@ defmodule BDS.Desktop.AutomationTest do end defp automation_process_counts do - %{app: count_processes("scripts/desktop_automation_app\\.exs"), driver: count_processes("desktop_automation_runner\\.mjs")} + %{ + app: count_processes("scripts/desktop_automation_app\\.exs"), + driver: count_processes("desktop_automation_runner\\.mjs") + } end defp count_processes(pattern) do diff --git a/test/bds/desktop/import_shell_live_test.exs b/test/bds/desktop/import_shell_live_test.exs index 43ca567..9f15a3e 100644 --- a/test/bds/desktop/import_shell_live_test.exs +++ b/test/bds/desktop/import_shell_live_test.exs @@ -13,7 +13,9 @@ defmodule BDS.Desktop.ImportShellLiveTest do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()}) - temp_dir = Path.join(System.tmp_dir!(), "bds-import-shell-live-#{System.unique_integer([:positive])}") + temp_dir = + Path.join(System.tmp_dir!(), "bds-import-shell-live-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) @@ -23,7 +25,8 @@ defmodule BDS.Desktop.ImportShellLiveTest do %{project: project, temp_dir: temp_dir} end - test "opening an import definition renders the dedicated import analysis editor instead of the fallback shell frame", %{project: project, temp_dir: temp_dir} do + test "opening an import definition renders the dedicated import analysis editor instead of the fallback shell frame", + %{project: project, temp_dir: temp_dir} do uploads_dir = Path.join(temp_dir, "uploads") wxr_path = Path.join(temp_dir, "legacy.xml") @@ -89,7 +92,13 @@ defmodule BDS.Desktop.ImportShellLiveTest do }, post_stats: %{new_count: 1, update_count: 0, conflict_count: 1, duplicate_count: 0}, page_stats: %{new_count: 1, update_count: 0, conflict_count: 0, duplicate_count: 0}, - media_stats: %{new_count: 1, update_count: 0, conflict_count: 0, duplicate_count: 0, missing_count: 0}, + media_stats: %{ + new_count: 1, + update_count: 0, + conflict_count: 0, + duplicate_count: 0, + missing_count: 0 + }, category_stats: %{existing_count: 0, mapped_count: 0, new_count: 1}, tag_stats: %{existing_count: 0, mapped_count: 0, new_count: 1}, date_distribution: [%{year: 2024, post_count: 2, media_count: 1}], @@ -119,7 +128,13 @@ defmodule BDS.Desktop.ImportShellLiveTest do items: %{ posts: [ %{item_type: "post", title: "Hello World", slug: "hello-world", status: "new"}, - %{item_type: "post", title: "Conflict Me", slug: "conflict-me", status: "conflict", resolution: "ignore"} + %{ + item_type: "post", + title: "Conflict Me", + slug: "conflict-me", + status: "conflict", + resolution: "ignore" + } ], pages: [ %{item_type: "page", title: "About", slug: "about", status: "new"} diff --git a/test/bds/desktop/main_window_test.exs b/test/bds/desktop/main_window_test.exs index 81b2e95..accb698 100644 --- a/test/bds/desktop/main_window_test.exs +++ b/test/bds/desktop/main_window_test.exs @@ -4,8 +4,14 @@ defmodule BDS.Desktop.MainWindowTest do alias BDS.Desktop.MainWindow setup do - path = Path.join(System.tmp_dir!(), "bds-main-window-state-#{System.unique_integer([:positive])}.json") + path = + Path.join( + System.tmp_dir!(), + "bds-main-window-state-#{System.unique_integer([:positive])}.json" + ) + previous = Application.get_env(:bds, :desktop, []) + updated = previous |> Keyword.put(:window_state_path, path) @@ -21,7 +27,9 @@ defmodule BDS.Desktop.MainWindowTest do %{path: path} end - test "window options use a smaller safe default and restore persisted size and position", %{path: path} do + test "window options use a smaller safe default and restore persisted size and position", %{ + path: path + } do opts = MainWindow.window_options() assert opts[:size] == {1280, 780} diff --git a/test/bds/desktop/overlay_test.exs b/test/bds/desktop/overlay_test.exs index f7ae1d9..786e122 100644 --- a/test/bds/desktop/overlay_test.exs +++ b/test/bds/desktop/overlay_test.exs @@ -23,7 +23,9 @@ defmodule BDS.Desktop.OverlayTest do assert language_picker.kind == :language_picker assert language_picker.source_language == "en" assert Enum.map(language_picker.available_targets, & &1.code) == ["de", "fr"] - assert Enum.find(language_picker.available_targets, &(&1.code == "de")).has_existing_translation == true + + assert Enum.find(language_picker.available_targets, &(&1.code == "de")).has_existing_translation == + true gallery = Overlay.open(:post, :gallery, context) @@ -74,15 +76,59 @@ defmodule BDS.Desktop.OverlayTest do current_tab: %{type: :post, id: "post-1", title: "Trip Notes", subtitle: "Draft"}, current_post_language: "en", posts: [ - %{id: "post-1", title: "Trip Notes", status: "draft", canonical_url: "/2026/04/26/trip-notes"}, - %{id: "post-2", title: "Photo Walk", status: "published", canonical_url: "/2026/04/26/photo-walk"}, - %{id: "post-3", title: "Travel Checklist", status: "draft", canonical_url: "/2026/04/20/travel-checklist"}, - %{id: "post-4", title: "Packing List", status: "archived", canonical_url: "/2026/03/18/packing-list"} + %{ + id: "post-1", + title: "Trip Notes", + status: "draft", + canonical_url: "/2026/04/26/trip-notes" + }, + %{ + id: "post-2", + title: "Photo Walk", + status: "published", + canonical_url: "/2026/04/26/photo-walk" + }, + %{ + id: "post-3", + title: "Travel Checklist", + status: "draft", + canonical_url: "/2026/04/20/travel-checklist" + }, + %{ + id: "post-4", + title: "Packing List", + status: "archived", + canonical_url: "/2026/03/18/packing-list" + } ], media: [ - %{id: "media-1", title: "Cover Shot", original_name: "cover-shot.jpg", is_image: true, thumbnail_url: "/media-thumbnail/media-1", image_url: "/media-thumbnail/media-1?size=large", alt_text: "Cover shot"}, - %{id: "media-2", title: "Street Scene", original_name: "street-scene.jpg", is_image: true, thumbnail_url: "/media-thumbnail/media-2", image_url: "/media-thumbnail/media-2?size=large", alt_text: "Street scene"}, - %{id: "media-3", title: "Audio Memo", original_name: "memo.m4a", is_image: false, thumbnail_url: nil, image_url: nil, alt_text: nil} + %{ + id: "media-1", + title: "Cover Shot", + original_name: "cover-shot.jpg", + is_image: true, + thumbnail_url: "/media-thumbnail/media-1", + image_url: "/media-thumbnail/media-1?size=large", + alt_text: "Cover shot" + }, + %{ + id: "media-2", + title: "Street Scene", + original_name: "street-scene.jpg", + is_image: true, + thumbnail_url: "/media-thumbnail/media-2", + image_url: "/media-thumbnail/media-2?size=large", + alt_text: "Street scene" + }, + %{ + id: "media-3", + title: "Audio Memo", + original_name: "memo.m4a", + is_image: false, + thumbnail_url: nil, + image_url: nil, + alt_text: nil + } ], post_media_ids: ["media-1", "media-2"], blog_languages: ["en", "de", "fr"], @@ -90,9 +136,27 @@ defmodule BDS.Desktop.OverlayTest do language_flags: %{"en" => "GB", "de" => "DE", "fr" => "FR"}, existing_translations: %{"de" => "draft"}, ai_fields: [ - %{key: "title", label: "Title", current_value: "Street Scene", suggested_value: "Street Scene at Dusk", locked: false}, - %{key: "alt", label: "Alt Text", current_value: "", suggested_value: "Street scene at dusk", locked: false}, - %{key: "caption", label: "Caption", current_value: "Busy corner", suggested_value: "A busy corner at dusk", locked: false} + %{ + key: "title", + label: "Title", + current_value: "Street Scene", + suggested_value: "Street Scene at Dusk", + locked: false + }, + %{ + key: "alt", + label: "Alt Text", + current_value: "", + suggested_value: "Street scene at dusk", + locked: false + }, + %{ + key: "caption", + label: "Caption", + current_value: "Busy corner", + suggested_value: "A busy corner at dusk", + locked: false + } ], delete_details: %{ entity_name: "Street Scene", diff --git a/test/bds/desktop/shell_commands_test.exs b/test/bds/desktop/shell_commands_test.exs index e6c923b..cee8c83 100644 --- a/test/bds/desktop/shell_commands_test.exs +++ b/test/bds/desktop/shell_commands_test.exs @@ -42,7 +42,9 @@ defmodule BDS.Desktop.ShellCommandsTest do %{project: project, temp_dir: temp_dir} end - test "open_in_browser starts preview for the active project and returns a preview url", %{project: project} do + test "open_in_browser starts preview for the active project and returns a preview url", %{ + project: project + } do assert {:ok, result} = ShellCommands.execute("open_in_browser") assert result.kind == "open_url" @@ -51,7 +53,10 @@ defmodule BDS.Desktop.ShellCommandsTest do assert result.project_id == project.id end - test "validate_translations returns an editor payload with current translation gaps", %{project: project, temp_dir: temp_dir} do + test "validate_translations returns an editor payload with current translation gaps", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, post} = BDS.Posts.create_post(%{ project_id: project.id, @@ -165,7 +170,8 @@ defmodule BDS.Desktop.ShellCommandsTest do assert is_map(completed.result.payload.summary) end - test "rebuild_posts_from_files rebuilds embeddings for published posts when semantic similarity is enabled", %{project: project} do + test "rebuild_posts_from_files rebuilds embeddings for published posts when semantic similarity is enabled", + %{project: project} do assert {:ok, _metadata} = BDS.Metadata.update_project_metadata(project.id, %{semantic_similarity_enabled: true}) @@ -178,7 +184,9 @@ defmodule BDS.Desktop.ShellCommandsTest do }) assert {:ok, published_post} = BDS.Posts.publish_post(post.id) - assert BDS.Repo.get_by(BDS.Embeddings.Key, project_id: project.id, post_id: published_post.id) != nil + + assert BDS.Repo.get_by(BDS.Embeddings.Key, project_id: project.id, post_id: published_post.id) != + nil BDS.Repo.delete_all(BDS.Embeddings.Key) @@ -186,10 +194,14 @@ defmodule BDS.Desktop.ShellCommandsTest do completed = wait_for_task(result.task_id, &(&1.status == :completed)) assert completed.group_name == "Maintenance" - assert BDS.Repo.get_by(BDS.Embeddings.Key, project_id: project.id, post_id: published_post.id) != nil + + assert BDS.Repo.get_by(BDS.Embeddings.Key, project_id: project.id, post_id: published_post.id) != + nil end - test "repair_metadata_diff exposes live in-task progress from the repair worker", %{project: project} do + test "repair_metadata_diff exposes live in-task progress from the repair worker", %{ + project: project + } do original = Application.get_env(:bds, :tasks, []) Application.put_env( @@ -216,7 +228,8 @@ defmodule BDS.Desktop.ShellCommandsTest do progressed = wait_for_task( result.task_id, - &(&1.status == :running and is_number(&1.progress) and &1.progress > 0.2 and &1.progress < 1.0), + &(&1.status == :running and is_number(&1.progress) and &1.progress > 0.2 and + &1.progress < 1.0), 5_000 ) @@ -243,7 +256,9 @@ defmodule BDS.Desktop.ShellCommandsTest do assert is_map(completed.result.payload.summary) end - test "rebuild_embedding_index exposes live in-task progress while rebuilding posts", %{project: project} do + test "rebuild_embedding_index exposes live in-task progress while rebuilding posts", %{ + project: project + } do original = Application.get_env(:bds, :tasks, []) original_embeddings = Application.get_env(:bds, :embeddings) @@ -289,7 +304,8 @@ defmodule BDS.Desktop.ShellCommandsTest do progressed = wait_for_task( result.task_id, - &(&1.status == :running and is_number(&1.progress) and &1.progress > 0.0 and &1.progress < 1.0 and + &(&1.status == :running and is_number(&1.progress) and &1.progress > 0.0 and + &1.progress < 1.0 and is_binary(&1.message) and String.contains?(&1.message, "/")), 10_000 ) @@ -297,7 +313,11 @@ defmodule BDS.Desktop.ShellCommandsTest do assert progressed.group_name == "Embeddings" assert String.contains?(progressed.message, "/") - assert wait_for_task(result.task_id, &(&1.status == :completed and &1.progress == 1.0), 10_000).status == + assert wait_for_task( + result.task_id, + &(&1.status == :completed and &1.progress == 1.0), + 10_000 + ).status == :completed end @@ -307,15 +327,19 @@ defmodule BDS.Desktop.ShellCommandsTest do assert result.kind == "task_queued" assert result.action == "rebuild_database" - tasks = wait_for_tasks_by_name([ - "Rebuild Posts From Files", - "Rebuild Media From Files", - "Rebuild Scripts From Files", - "Rebuild Templates From Files", - "Rebuild Post Links", - "Regenerate Missing Thumbnails", - "Rebuild Embedding Index" - ], &(&1.status == :completed)) + tasks = + wait_for_tasks_by_name( + [ + "Rebuild Posts From Files", + "Rebuild Media From Files", + "Rebuild Scripts From Files", + "Rebuild Templates From Files", + "Rebuild Post Links", + "Regenerate Missing Thumbnails", + "Rebuild Embedding Index" + ], + &(&1.status == :completed) + ) assert Enum.all?(tasks, &(&1.group_name == "Maintenance")) assert Enum.all?(tasks, &(&1.status == :completed)) @@ -429,32 +453,40 @@ defmodule BDS.Desktop.ShellCommandsTest do _posts_task = wait_for_named_task( "Rebuild Posts From Files", - &(&1.status == :running and is_number(&1.progress) and &1.progress > 0.0 and &1.progress < 1.0), + &(&1.status == :running and is_number(&1.progress) and &1.progress > 0.0 and + &1.progress < 1.0), 10_000 ) phase_one_tasks = BDS.Tasks.list_tasks() - |> Enum.filter(&(&1.name in [ - "Rebuild Posts From Files", - "Rebuild Media From Files", - "Rebuild Scripts From Files", - "Rebuild Templates From Files" - ])) + |> Enum.filter( + &(&1.name in [ + "Rebuild Posts From Files", + "Rebuild Media From Files", + "Rebuild Scripts From Files", + "Rebuild Templates From Files" + ]) + ) assert Enum.count(phase_one_tasks, &(&1.status == :running)) == 1 assert Enum.find(phase_one_tasks, &(&1.status == :running)).name == "Rebuild Posts From Files" - tasks = wait_for_tasks_by_name([ - "Rebuild Posts From Files", - "Rebuild Media From Files", - "Rebuild Scripts From Files", - "Rebuild Templates From Files", - "Rebuild Post Links", - "Regenerate Missing Thumbnails", - "Rebuild Embedding Index" - ], &(&1.status == :completed), 20_000) + tasks = + wait_for_tasks_by_name( + [ + "Rebuild Posts From Files", + "Rebuild Media From Files", + "Rebuild Scripts From Files", + "Rebuild Templates From Files", + "Rebuild Post Links", + "Regenerate Missing Thumbnails", + "Rebuild Embedding Index" + ], + &(&1.status == :completed), + 20_000 + ) assert Enum.all?(tasks, &(&1.status == :completed)) end @@ -505,7 +537,8 @@ defmodule BDS.Desktop.ShellCommandsTest do progressed = wait_for_named_task( "Rebuild Posts From Files", - &(&1.status == :running and is_number(&1.progress) and &1.progress > 0.0 and &1.progress < 1.0), + &(&1.status == :running and is_number(&1.progress) and &1.progress > 0.0 and + &1.progress < 1.0), 5_000 ) @@ -515,15 +548,20 @@ defmodule BDS.Desktop.ShellCommandsTest do assert wait_for_task(progressed.id, &(&1.status == :completed and &1.progress == 1.0), 5_000).status == :completed - tasks = wait_for_tasks_by_name([ - "Rebuild Posts From Files", - "Rebuild Media From Files", - "Rebuild Scripts From Files", - "Rebuild Templates From Files", - "Rebuild Post Links", - "Regenerate Missing Thumbnails", - "Rebuild Embedding Index" - ], &(&1.status == :completed), 20_000) + tasks = + wait_for_tasks_by_name( + [ + "Rebuild Posts From Files", + "Rebuild Media From Files", + "Rebuild Scripts From Files", + "Rebuild Templates From Files", + "Rebuild Post Links", + "Regenerate Missing Thumbnails", + "Rebuild Embedding Index" + ], + &(&1.status == :completed), + 20_000 + ) assert Enum.all?(tasks, &(&1.status == :completed)) end @@ -537,7 +575,11 @@ defmodule BDS.Desktop.ShellCommandsTest do assert is_binary(result.task_id) assert is_binary(result.task_group_id) - tasks = wait_for_tasks_by_name(["Reindex Search Text", "Reindex Media Search Text"], &(&1.status == :completed)) + tasks = + wait_for_tasks_by_name( + ["Reindex Search Text", "Reindex Media Search Text"], + &(&1.status == :completed) + ) assert Enum.all?(tasks, &(&1.group_name == "Search")) assert Enum.all?(tasks, &(&1.group_id == result.task_group_id)) diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 8771745..df8478e 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -8,8 +8,10 @@ defmodule BDS.Desktop.ShellLiveTest do test "shell live modules use contexts instead of direct Repo.get calls" do source_files = - [Path.expand("../../../lib/bds/desktop/shell_live.ex", __DIR__) | - Path.wildcard(Path.join(@shell_live_source_root, "**/*.ex"))] + [ + Path.expand("../../../lib/bds/desktop/shell_live.ex", __DIR__) + | Path.wildcard(Path.join(@shell_live_source_root, "**/*.ex")) + ] offenders = source_files @@ -46,12 +48,20 @@ defmodule BDS.Desktop.ShellLiveTest do defmodule FakeEndpointModelHttpClient do def get("https://api.example.test/v1/models", _headers) do {:ok, - %{status: 200, headers: %{}, body: Jason.encode!(%{"data" => [%{"id" => "gpt-4.1"}, %{"id" => "gpt-4.1-mini"}]})}} + %{ + status: 200, + headers: %{}, + body: Jason.encode!(%{"data" => [%{"id" => "gpt-4.1"}, %{"id" => "gpt-4.1-mini"}]}) + }} end def get("http://localhost:11434/v1/models", _headers) do {:ok, - %{status: 200, headers: %{}, body: Jason.encode!(%{"data" => [%{"id" => "llama3.3"}, %{"id" => "llava:latest"}]})}} + %{ + status: 200, + headers: %{}, + body: Jason.encode!(%{"data" => [%{"id" => "llama3.3"}, %{"id" => "llava:latest"}]}) + }} end def get(_url, _headers), do: {:error, :not_found} @@ -61,8 +71,8 @@ defmodule BDS.Desktop.ShellLiveTest do use Plug.Router import Phoenix.ConnTest, except: [post: 2] - plug :match - plug :dispatch + plug(:match) + plug(:dispatch) post "/v1/chat/completions" do Process.sleep(300) @@ -89,7 +99,9 @@ defmodule BDS.Desktop.ShellLiveTest do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()}) - temp_dir = Path.join(System.tmp_dir!(), "bds-shell-live-#{System.unique_integer([:positive])}") + temp_dir = + Path.join(System.tmp_dir!(), "bds-shell-live-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) @@ -158,7 +170,9 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(data-sidebar-action="import") end - test "sidebar create actions follow the old-app post, script, template, and import flows", %{project: project} do + test "sidebar create actions follow the old-app post, script, template, and import flows", %{ + project: project + } do {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) post_count_before = Repo.aggregate(Post, :count, :id) script_count_before = Repo.aggregate(BDS.Scripts.Script, :count, :id) @@ -218,7 +232,8 @@ defmodule BDS.Desktop.ShellLiveTest do |> element("[data-testid='sidebar-create-action'][data-sidebar-action='import']") |> render_click() - assert Repo.aggregate(ImportDefinitions.ImportDefinition, :count, :id) == import_count_before + 1 + assert Repo.aggregate(ImportDefinitions.ImportDefinition, :count, :id) == + import_count_before + 1 created_definition = Repo.one!(ImportDefinitions.ImportDefinition) assert created_definition.project_id == project.id @@ -227,19 +242,27 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(data-tab-id="#{created_definition.id}") end - test "shell live refreshes the posts sidebar when the CLI watcher broadcasts an entity change", %{project: project} do + test "shell live refreshes the posts sidebar when the CLI watcher broadcasts an entity change", + %{project: project} do {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) refute html =~ "CLI Added Post" assert {:ok, post} = Posts.create_post(%{project_id: project.id, title: "CLI Added Post"}) - Phoenix.PubSub.broadcast(BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "post", entity_id: post.id, action: :created}}) + Phoenix.PubSub.broadcast( + BDS.PubSub, + Watcher.topic(), + {:entity_changed, %{entity: "post", entity_id: post.id, action: :created}} + ) assert render(view) =~ "CLI Added Post" end - test "shell live closes stale post and media tabs when the CLI watcher broadcasts deletions", %{project: project, temp_dir: temp_dir} do + test "shell live closes stale post and media tabs when the CLI watcher broadcasts deletions", %{ + project: project, + temp_dir: temp_dir + } do assert {:ok, post} = Posts.create_post(%{project_id: project.id, title: "CLI Delete Post"}) source_path = Path.join(temp_dir, "cli-delete-media.txt") @@ -264,7 +287,11 @@ defmodule BDS.Desktop.ShellLiveTest do assert {:ok, :deleted} = Posts.delete_post(post.id) - Phoenix.PubSub.broadcast(BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "post", entity_id: post.id, action: :deleted}}) + Phoenix.PubSub.broadcast( + BDS.PubSub, + Watcher.topic(), + {:entity_changed, %{entity: "post", entity_id: post.id, action: :deleted}} + ) html = render(view) refute html =~ ~s(data-tab-type="post") @@ -285,7 +312,11 @@ defmodule BDS.Desktop.ShellLiveTest do assert {:ok, :deleted} = Media.delete_media(media.id) - Phoenix.PubSub.broadcast(BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "media", entity_id: media.id, action: :deleted}}) + Phoenix.PubSub.broadcast( + BDS.PubSub, + Watcher.topic(), + {:entity_changed, %{entity: "media", entity_id: media.id, action: :deleted}} + ) html = render(view) refute html =~ ~s(data-tab-type="media") @@ -624,7 +655,14 @@ defmodule BDS.Desktop.ShellLiveTest do test "shell live renders the legacy git activity badge from remote behind count" do Application.put_env(:bds, :git_remote_state_provider, fn _project_id, _opts -> - {:ok, %{local_branch: "main", upstream_branch: "origin/main", has_upstream: true, ahead: 0, behind: 7}} + {:ok, + %{ + local_branch: "main", + upstream_branch: "origin/main", + has_upstream: true, + ahead: 0, + behind: 7 + }} end) {:ok, _view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) @@ -906,7 +944,8 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(style="width: 280px;") - html = render_hook(view, "sync_layout", %{"sidebar_width" => 420, "assistant_sidebar_width" => 480}) + html = + render_hook(view, "sync_layout", %{"sidebar_width" => 420, "assistant_sidebar_width" => 480}) assert html =~ ~s(data-testid="sidebar-shell") assert html =~ ~s(style="width: 420px;") @@ -928,7 +967,8 @@ defmodule BDS.Desktop.ShellLiveTest do test "sidebar filters and load more are server-driven", %{project: project} do seed_sidebar_posts(project.id) - assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "tech", color: "#112233"}) + assert {:ok, _tag} = + Tags.create_tag(%{project_id: project.id, name: "tech", color: "#112233"}) {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) @@ -937,7 +977,10 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(class="sidebar-section-header") assert html =~ ~s(class="sidebar-actions") assert html =~ ~s(data-testid="sidebar-load-more") - assert html_position(html, ~s(data-testid="sidebar-load-more")) > html_position(html, ">Archived<") + + assert html_position(html, ~s(data-testid="sidebar-load-more")) > + html_position(html, ">Archived<") + refute html =~ ~s(data-testid="sidebar-filter-tag") assert html =~ "Alpha Post" refute html =~ "Overflow Post" @@ -998,9 +1041,18 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ "Overflow Post" end - test "project switcher, ui language, dashboard recents, and output log are wired", %{temp_dir: temp_dir} do - {:ok, other_project} = Projects.create_project(%{name: "Second Blog", data_path: Path.join(temp_dir, "second")}) - {:ok, recent_post} = Posts.create_post(%{project_id: other_project.id, title: "Recent Shell Post", content: "body"}) + test "project switcher, ui language, dashboard recents, and output log are wired", %{ + temp_dir: temp_dir + } do + {:ok, other_project} = + Projects.create_project(%{name: "Second Blog", data_path: Path.join(temp_dir, "second")}) + + {:ok, recent_post} = + Posts.create_post(%{ + project_id: other_project.id, + title: "Recent Shell Post", + content: "body" + }) {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) @@ -1044,13 +1096,22 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ "Activated Second Blog" end - test "task button opens tasks and post panels render real link and git data", %{project: project, temp_dir: temp_dir} do - {:ok, target} = Posts.create_post(%{project_id: project.id, title: "Target Post", content: "target body"}) + test "task button opens tasks and post panels render real link and git data", %{ + project: project, + temp_dir: temp_dir + } do + {:ok, target} = + Posts.create_post(%{project_id: project.id, title: "Target Post", content: "target body"}) + {:ok, target} = Posts.publish_post(target.id) target_href = canonical_post_href(target) {:ok, source} = - Posts.create_post(%{project_id: project.id, title: "Linking Source", content: "See [Target](#{target_href})"}) + Posts.create_post(%{ + project_id: project.id, + title: "Linking Source", + content: "See [Target](#{target_href})" + }) {:ok, source} = Posts.publish_post(source.id) :ok = Posts.rebuild_post_links(project.id) @@ -1087,7 +1148,10 @@ defmodule BDS.Desktop.ShellLiveTest do |> render_click() refute html =~ ~s(class="panel-shell is-hidden") - assert html =~ ~s(