diff --git a/CODESMELL.md b/CODESMELL.md index 2e0419c..57fdbc4 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -1,6 +1,8 @@ # Elixir Code Smell Analysis — bDS2 > Generated by static analysis of the codebase. Do not delete; use as a reference for refactoring sprints. +> +> **Updated 2026-04-30:** Each claim re-verified against current code. See "Verification Summary" and "Priority Order" sections at the bottom. --- @@ -18,10 +20,11 @@ Several modules are far too large and violate the single-responsibility principl | Module | Lines | Issue | |--------|-------|-------| -| `BDS.Desktop.ShellLive` | ~2,600 | Handles UI events, routing, overlays, menus, project switching, tab management, translations, task polling, and native menu integration | -| `BDS.Posts` | ~1,700 | Handles CRUD, publishing, file I/O, translations, auto-translation scheduling, embeddings sync, search sync, link rebuilding, and validation | -| `BDS.Generation` | ~1,500 | Site generation, validation, sitemap building, archive pagination, feed rendering, and file hashing | -| `BDS.MCP` | ~670 | MCP tools, resources, proposals, post detail serialization, search, and category counting | +| `BDS.Desktop.ShellLive` | 2,607 | Handles UI events, routing, overlays, menus, project switching, tab management, translations, task polling, and native menu integration | +| `BDS.Posts` | 1,709 | Handles CRUD, publishing, file I/O, translations, auto-translation scheduling, embeddings sync, search sync, link rebuilding, and validation | +| `BDS.Generation` | 2,624 | Site generation, validation, sitemap building, archive pagination, feed rendering, and file hashing (verified larger than originally documented) | +| `BDS.MCP` | 670 | MCP tools, resources, proposals, post detail serialization, search, and category counting | +| `BDS.Media` | 939 | Media CRUD, thumbnail generation, sidecar I/O, post-media join management | **Why this is an anti-pattern:** Giant modules reduce compiler concurrency, make refactoring dangerous, obscure test coverage, and increase merge conflicts. Elixir/OTP best practice is to keep modules under ~400 lines and split by responsibility. @@ -30,7 +33,7 @@ Several modules are far too large and violate the single-responsibility principl --- ### 2. Process Dictionary for I18n State - + (and 13 other call sites across all `*_editor.ex` modules — 15 total) In `BDS.Desktop.ShellLive`: ```elixir @@ -53,7 +56,7 @@ defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, --- ### 3. Side Effects Inside Database Transactions - + (7 transactions in `lib/bds/media.ex` follow this pattern) In `BDS.Media.import_media/1`: ```elixir @@ -87,6 +90,8 @@ end **Why this is an anti-pattern:** Atoms are not garbage collected. If MCP receives large or arbitrary JSON keys, the VM atom table can fill up and crash the BEAM. **Fix:** Use `String.to_existing_atom/1` (with a safe fallback) or keep keys as strings. +Note: 10 other `String.to_atom` call sites exist, but they operate on bounded enums (modality, platform, view id) and are lower risk. + --- @@ -102,7 +107,7 @@ defp parse_rebuild_file(project, path) do end ``` -**Why this is an anti-pattern:** Missing files or permission errors raise unhandled exceptions that crash the caller. In a GenServer context (e.g., background tasks), this brings down the worker. +**Why this is an anti-pattern:** Missing files or permission errors raise unhandled exceptions that crash the caller Limit the change to files reached from long-running processes; mandatory configuration files can stay as `File.read!`.. In a GenServer context (e.g., background tasks), this brings down the worker. **Fix:** Use `File.read/1` and handle `{:error, reason}` explicitly, returning `{:error, reason}` tuples to callers. @@ -124,7 +129,7 @@ The same private helpers are copy-pasted in `BDS.Posts`, `BDS.Media`, `BDS.Searc --- -### 7. Direct Repo Access in LiveView +### 7. Direct Repo Access in LiveView (10 sites verified) `BDS.Desktop.ShellLive` directly queries the repo: @@ -175,7 +180,7 @@ end --- -### 10. `Jason.decode!` Without Error Handling +### 10. `Jason.decode!` Without Err and `BDS.AI` (HTTP-response decoding)or Handling In `BDS.AI.OpenAICompatibleRuntime`: @@ -186,9 +191,7 @@ defp normalize_response(body) do end ``` -**Why this is an anti-pattern:** Malformed JSON from an external API will crash the caller. - -**Fix:** Use `Jason.decode/1` and propagate `{:error, reason}`. +**Why this is an anti-pattern:** Malformed JSON from an externa Limit the change to HTTP-response decoding; decoding of our own on-disk files (`metadata.ex`, `embeddings/index.ex`, etc.) is acceptable. --- @@ -196,14 +199,19 @@ end ### 11. Missing `@spec` Typespecs -Very few public functions have `@spec` declarations. For a project of this size, this makes Dialyzer less effective and the API surface harder to understand for new contributors. +Verified: only **10 `@spec` declarations across all of `lib/`**, and they are concentrated in `BDS.Scripting` / `BDS.Scripting.Lua`. Every other public context function relies on Dialyzer inference. For a project of this size, adding `@spec` to public APIs is the single highest-ROI type-safety improvement available — and pairs well with the existing `mix dialyzer --format short` check. ### 12. Tests Run Synchronously -All tests visible use `use ExUnit.Case, async: false`. For a large suite, this unnecessarily slows CI and local feedback loops. Use `async: true` where tests are isolated. +Most tests use `use ExUnit.Case, async: false`. Many tests share the SQLite repo and named GenServers, so they cannot realistically be async. Limit conversion to pure-logic test files (parsers, formatters, etc.). ### 13. Raw SQL Overuse +19 `Repo.query` sites split into two groups: + +1. **FTS5 virtual tables** in `BDS.Search` (`posts_fts`, `media_fts`, `MATCH`, `bm25`, `rank`). Ecto cannot express FTS5 queries cleanly. **Keep these raw.** +2. **`post_media` join table** queried by hand in `BDS.Posts`, `BDS.Media`, `BDS.Desktop.ShellLive.OverlayComponents`, `BDS.Desktop.ShellLive.PostEditor`. There is no `BDS.PostMedia` schema. This is the real type-safety hole; introducing the schema replaces 6–8 raw queries with Ecto + While SQLite FTS necessitates some raw SQL, many queries in `BDS.Search`, `BDS.Media`, and `BDS.Posts` use `Repo.query!/2` for standard operations that Ecto could express portably (e.g., `DELETE FROM ... WHERE ...`). This reduces type safety and database portability. ### 14. Atom/String Key Duality @@ -229,8 +237,88 @@ This suggests data isn't normalized at boundaries. Prefer atoms for internal str - **`with` blocks** handle multi-step error flows cleanly. - **Pattern matching** is idiomatic and pervasive. - **Task.Supervisor** is used correctly for background work. -- **Atomic file writes** (`Persistence.atomic_write`) show good filesystem hygiene. -- **PubSub** is used appropriately for CLI sync notifications. +- *Verification Summary (2026-04-30) + +| # | Claim | Verified | Notes | +|---|---|---|---| +| 1 | Module bloat | ✅ | `Generation` is 2624 lines, worse than originally documented. | +| 2 | `Process.put(:bds_ui_locale)` | ✅ | 15 call sites across `shell_live.ex` and every `*_editor.ex`. | +| 3 | Side effects in transactions | ✅ | 7 transactions in `lib/bds/media.ex` wrap filesystem + Search side effects. | +| 4 | `String.to_atom` on untrusted input | ⚠️ Partly | Only `MCP.atomize_keys` is on arbitrary external JSON. Other 10 sites are bounded enums. | +| 5 | `File.read!` | ⚠️ Partly | Most sites read mandatory config; only loop/background paths are real risk. | +| 6 | Duplicated helpers | ✅ | Real DRY violation across contexts. | +| 7 | `Repo.get` in `ShellLive` | ✅ | 10 direct calls. | +| 8/9 | `to_existing_atom` + rescue | ⚠️ | The rescue effectively *is* the whitelist; cosmetic priority. | +| 10 | `Jason.decode!` on external responses | ⚠️ Partly | Only 2 of 14 sites decode HTTP responses; the rest decode our own files. | +| 11 | Missing `@spec` | ✅ | 10 specs in entire `lib/`. Highest type-safety ROI. | +| 12 | Tests synchronous | ✅ | Limited convertibility — most tests share repo/GenServers. | +| 13 | Raw SQL | ⚠️ Partly | FTS5 must stay raw; `post_media` table is the real fix target. | + +--- + +## Priority Order + +1. **Add `@spec` to public APIs of core contexts.** ✅ **DONE 2026-04-30.** See "Priority #1 Completion" section below. +2. **Extract filesystem / Search side effects out of `Repo.transaction` in `BDS.Media`.** Real correctness bug under SQLite busy retries. +3. **Fix `MCP.atomize_keys`** to use `String.to_existing_atom/1` with a string-fallback. Removes a real atom-table DoS vector. +4. **Introduce `BDS.PostMedia` Ecto schema** and migrate the 6–8 raw `post_media` queries. Direct type-safety win. +5. **Replace `Repo.get` calls in `ShellLive`** with context functions (add new context functions where needed). +6. **Move locale from `Process.put` into assigns**, then ban `Process.put` via Credo. +7. **Extract shared helpers** (`attr/2`, `maybe_put/3`, `blank_to_nil/1`, `progress_callback/1`, rebuild progress reporters) into `BDS.MapUtils` / `BDS.ProgressReporter`. +8. **Wrap external `Jason.decode!` calls** in `BDS.AI.OpenAICompatibleRuntime` and `BDS.AI` with `Jason.decode/1` + `{:error, _}` propagation. +9. **Module split.** `BDS.Generation` (2624) and `BDS.Desktop.ShellLive` (2607) first; schedule each as its own sprint. + +### Skipped / downgraded + +- #5 in bulk — convert only paths reached from long-running processes. +- #8 / #9 — cosmetic; the rescue functions as a whitelist today. +- #12 — leave `async: false` where the repo or named GenServers are involved. +- #15 — bounded in practice; ensure UI triggers `clear_finished` periodically. + +--- + +## Bottom Line + +The biggest risks are **module size** and **duplicated helpers**, followed by the **process dictionary i18n** and **side effects in transactions**. The single highest-ROI improvement for type safety is **adding `@spec` to public context APIs** so Dialyzer can do its job — that is the starting point of the priority order above + +--- + +## Priority #1 Completion (2026-04-30) + +Added `@spec` and `@type` declarations to the public APIs of all core contexts and their schemas. Validation: `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4. + +**Files modified (specs added):** + +- `lib/bds/projects.ex`, `lib/bds/projects/project.ex` +- `lib/bds/posts.ex`, `lib/bds/posts/post.ex`, `lib/bds/posts/translation.ex`, `lib/bds/posts/link.ex` +- `lib/bds/media.ex`, `lib/bds/media/media.ex`, `lib/bds/media/translation.ex` +- `lib/bds/search.ex` +- `lib/bds/publishing.ex`, `lib/bds/publishing/publish_job.ex` +- `lib/bds/generation.ex` +- `lib/bds/metadata.ex` +- `lib/bds/mcp.ex` +- `lib/bds/ai.ex` + +**Bugs surfaced and fixed by Dialyzer once specs were in place:** + +- `BDS.Search.list_stemmer_languages/0` returns `[String.t()]`, not `[{String.t(), [String.t()]}]`. +- `BDS.Media.sync_media_sidecar/1` returns `:ok` (not `{:ok, t()}`); the `posts.ex` caller was already pattern-matching on `:ok`. +- `BDS.Media.replace_media_file/2` can return `{:ok, nil}` when the new file's checksum is unchanged. +- Removed unreachable `other -> {:error, other}` fall-through clauses in the auto-translate cascades in `BDS.Posts` (the preceding `{:error, reason}` pattern already covers the only remaining return shape). +- Removed unreachable `defp blank?(_value)` clause in `BDS.Desktop.ShellLive.ChatEditor` (prior clauses already covered `binary` and `nil`, no other types reach the function). + +**Conventions established for future spec work:** + +- Ecto schemas need explicit `@type t` — Ecto does not generate one. +- Use `term()` for `belongs_to` / `has_many` association fields to avoid circular type dependencies between sibling schemas. +- Use `@typedoc` + named types (e.g. `attrs`, `metadata_state`, `search_filters`, `reindex_opts`) to avoid repeating large map shapes across many specs in the same module. +- For `update_*` / attrs-style maps, the canonical type is `%{optional(atom()) => term(), optional(String.t()) => term()}` because both renderer-supplied string keys and Elixir atom keys flow into them. + +**Not yet specced (intentional, out of scope of priority #1):** + +- LiveView modules under `lib/bds/desktop/shell_live/`. These are Phoenix LiveView callbacks (`mount/3`, `handle_event/3`, `handle_info/2`) whose specs are inherited from the behaviour. Public helper functions in those modules can be specced as part of the eventual ShellLive module split (priority #9). +- Internal helpers in `BDS.MCP` and `BDS.AI` that are private (`defp`) — Dialyzer infers these. +- `BDS.Tags`, `BDS.Templates`, `BDS.Scripts`, `BDS.PostLinks` — smaller contexts; queue for a follow-up pass if Dialyzer surfaces issues. --- diff --git a/lib/bds/ai.ex b/lib/bds/ai.ex index 1f7998d..3075e4a 100644 --- a/lib/bds/ai.ex +++ b/lib/bds/ai.ex @@ -34,6 +34,17 @@ defmodule BDS.AI do airplane_image_analysis: "ai.airplane.model.image_analysis" } + @typedoc "Endpoint kind such as :chat, :airplane_chat, :embedding, etc." + @type endpoint_kind :: atom() + + @typedoc "Endpoint configuration map." + @type endpoint :: %{kind: endpoint_kind(), url: String.t() | nil, api_key: String.t() | nil, model: String.t() | nil} + + @typedoc "Attribute map for endpoint operations." + @type endpoint_attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} + + @spec put_endpoint(endpoint_kind(), endpoint_attrs(), keyword()) :: + {:ok, endpoint()} | {:error, term()} def put_endpoint(kind, attrs, opts \\ []) when is_atom(kind) and is_map(attrs) and is_list(opts) do backend = Keyword.get(opts, :secret_backend, SecretBackend) kind_key = Atom.to_string(kind) @@ -49,6 +60,8 @@ defmodule BDS.AI do end end + @spec get_endpoint(endpoint_kind(), keyword()) :: + {:ok, endpoint() | nil} | {:error, term()} def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do backend = Keyword.get(opts, :secret_backend, SecretBackend) kind_key = Atom.to_string(kind) @@ -67,6 +80,7 @@ defmodule BDS.AI do end end + @spec delete_endpoint(endpoint_kind()) :: :ok def delete_endpoint(kind) when is_atom(kind) do kind_key = Atom.to_string(kind) delete_setting("ai.#{kind_key}.url") @@ -75,11 +89,15 @@ defmodule BDS.AI do :ok end + @spec list_endpoint_models(map(), keyword()) :: {:ok, [map()]} | {:error, term()} def list_endpoint_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do http_client = Keyword.get(opts, :http_client, Application.get_env(:bds, :ai_http_client, BDS.AI.HttpClient)) OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client) end + @spec refresh_model_catalog(keyword()) :: + {:ok, %{success: boolean(), models_updated: non_neg_integer(), not_modified: boolean()}} + | {:error, term()} def refresh_model_catalog(opts \\ []) when is_list(opts) do http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient) @@ -111,6 +129,7 @@ defmodule BDS.AI do end end + @spec list_catalog_providers() :: [map()] def list_catalog_providers do Repo.all(from provider in CatalogProvider, order_by: [asc: provider.id]) |> Enum.map(fn provider -> @@ -126,6 +145,7 @@ defmodule BDS.AI do end) end + @spec get_catalog_model(String.t(), String.t() | nil) :: {:ok, map()} | {:error, :not_found} def get_catalog_model(model_id, provider_id \\ nil) when is_binary(model_id) do query = from model in Model, @@ -144,14 +164,17 @@ defmodule BDS.AI do end end + @spec catalog_meta(String.t()) :: {:ok, String.t() | nil} def catalog_meta(key) when is_binary(key) do {:ok, get_catalog_meta_value(key)} end + @spec set_airplane_mode(boolean()) :: :ok | {:error, term()} def set_airplane_mode(enabled) when is_boolean(enabled) do put_setting("ai.airplane_mode_enabled", Atom.to_string(enabled)) end + @spec airplane_mode?(boolean()) :: boolean() def airplane_mode?(default \\ false) when is_boolean(default) do case get_setting("ai.airplane_mode_enabled") do nil -> default @@ -160,6 +183,7 @@ defmodule BDS.AI do end end + @spec put_model_preference(atom(), String.t()) :: :ok | {:error, :unknown_model_preference | term()} def put_model_preference(key, model) when is_atom(key) and is_binary(model) do case Map.fetch(@model_preference_keys, key) do {:ok, setting_key} -> put_setting(setting_key, model) @@ -167,6 +191,7 @@ defmodule BDS.AI do end end + @spec get_model_preference(atom()) :: {:ok, String.t() | nil} | {:error, :unknown_model_preference} def get_model_preference(key) when is_atom(key) do case Map.fetch(@model_preference_keys, key) do {:ok, setting_key} -> {:ok, get_setting(setting_key)} @@ -174,6 +199,7 @@ defmodule BDS.AI do end end + @spec put_model_capabilities(String.t(), map()) :: :ok | {:error, term()} def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do capabilities = %{ supports_attachment: truthy?(Map.get(attrs, :supports_attachment) || Map.get(attrs, "supports_attachment")), @@ -183,6 +209,7 @@ defmodule BDS.AI do put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities)) end + @spec detect_language(String.t(), keyword()) :: {:ok, map()} | {:error, term()} def detect_language(text, opts \\ []) when is_binary(text) and is_list(opts) do run_one_shot( :detect_language, @@ -194,6 +221,7 @@ defmodule BDS.AI do ) end + @spec analyze_taxonomy(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()} def analyze_taxonomy(post_input, opts \\ []) when is_list(opts) do with {:ok, post} <- normalize_post_input(post_input) do run_one_shot( @@ -212,6 +240,7 @@ defmodule BDS.AI do end end + @spec analyze_import_taxonomy(map(), map(), keyword()) :: {:ok, map()} | {:error, term()} def analyze_import_taxonomy(import_terms, existing_terms, opts \\ []) when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do payload = %{ @@ -246,6 +275,7 @@ defmodule BDS.AI do ) end + @spec analyze_post(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()} def analyze_post(post_input, opts \\ []) when is_list(opts) do with {:ok, post} <- normalize_post_input(post_input) do run_one_shot( @@ -265,6 +295,7 @@ defmodule BDS.AI do end end + @spec translate_post(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} def translate_post(post_input, target_language, opts \\ []) when is_binary(target_language) and is_list(opts) do with {:ok, post} <- normalize_post_input(post_input) do @@ -285,6 +316,7 @@ defmodule BDS.AI do end end + @spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()} def analyze_image(media_input, opts \\ []) when is_list(opts) do with {:ok, media} <- normalize_media_input(media_input), :ok <- ensure_image_media(media) do @@ -305,6 +337,7 @@ defmodule BDS.AI do end end + @spec translate_media(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} def translate_media(media_input, target_language, opts \\ []) when is_binary(target_language) and is_list(opts) do with {:ok, media} <- normalize_media_input(media_input) do @@ -325,6 +358,7 @@ defmodule BDS.AI do end end + @spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()} def start_chat(attrs \\ %{}) when is_map(attrs) do now = Persistence.now_ms() model = Map.get(attrs, :model) || Map.get(attrs, "model") @@ -346,11 +380,13 @@ defmodule BDS.AI do end end + @spec list_chat_conversations() :: [map()] def list_chat_conversations do Repo.all(from conversation in ChatConversation, order_by: [desc: conversation.updated_at]) |> Enum.map(&format_conversation/1) end + @spec available_chat_models(String.t() | nil) :: [map()] def available_chat_models(current_model \\ nil) do endpoint_models = configured_chat_models() @@ -378,6 +414,8 @@ defmodule BDS.AI do end) end + @spec set_conversation_model(String.t(), String.t()) :: + {:ok, map()} | {:error, :not_found | Ecto.Changeset.t()} def set_conversation_model(conversation_id, model_id) when is_binary(conversation_id) and is_binary(model_id) do case Repo.get(ChatConversation, conversation_id) do @@ -395,6 +433,7 @@ defmodule BDS.AI do end end + @spec list_chat_messages(String.t()) :: [map()] def list_chat_messages(conversation_id) when is_binary(conversation_id) do Repo.all( from message in ChatMessage, @@ -404,6 +443,8 @@ defmodule BDS.AI do |> Enum.map(&format_chat_message/1) end + @spec send_chat_message(String.t(), String.t(), keyword()) :: + {:ok, map()} | {:error, :not_found | term()} def send_chat_message(conversation_id, content, opts \\ []) when is_binary(conversation_id) and is_binary(content) and is_list(opts) do with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id), @@ -437,6 +478,7 @@ defmodule BDS.AI do end end + @spec cancel_chat(String.t()) :: :ok def cancel_chat(conversation_id) when is_binary(conversation_id) do case InFlight.lookup(conversation_id) do nil -> :ok diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index c6874da..7e7d844 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -915,7 +915,6 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do defp blank?(value) when is_binary(value), do: String.trim(value) == "" defp blank?(nil), do: true - defp blank?(_value), do: false defp rewrite_external_images(html) do html = diff --git a/lib/bds/generation.ex b/lib/bds/generation.ex index 73ac172..c3117f1 100644 --- a/lib/bds/generation.ex +++ b/lib/bds/generation.ex @@ -18,6 +18,19 @@ defmodule BDS.Generation do @core_sections [:core, :single, :category, :tag, :date] + @typedoc "A section identifier accepted by `generate_site/3` and friends." + @type section :: :core | :single | :category | :tag | :date + + @typedoc "Options accepted by long-running generation operations." + @type generation_opts :: keyword() + + @typedoc "Plan returned by `plan_generation/2`." + @type plan :: map() + + @typedoc "Validation report returned by `validate_site/3`." + @type validation_report :: map() + + @spec plan_generation(String.t(), [section()]) :: {:ok, plan()} def plan_generation(project_id, sections \\ [:core]) when is_binary(project_id) and is_list(sections) do project = Projects.get_project!(project_id) @@ -40,6 +53,8 @@ defmodule BDS.Generation do }} end + @spec generate_site(String.t(), [section()], generation_opts()) :: + {:ok, %{sections: [section()], generated_files: [map()]}} | {:error, term()} def generate_site(project_id, sections \\ [:core], opts \\ []) def generate_site(project_id, sections, opts) @@ -63,6 +78,8 @@ defmodule BDS.Generation do end end + @spec validate_site(String.t(), [section()], generation_opts()) :: + {: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 @@ -189,6 +206,7 @@ defmodule BDS.Generation do :ok end + @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) do expected_outputs = build_outputs(plan) @@ -283,8 +301,10 @@ defmodule BDS.Generation do end end + @spec post_output_path(map()) :: String.t() def post_output_path(post), do: post_output_path(post, nil) + @spec post_output_path(map(), String.t() | nil) :: String.t() def post_output_path(post, language) when is_map(post) do {year, month, day} = local_date_parts!(post.created_at) year = Integer.to_string(year) @@ -300,9 +320,14 @@ defmodule BDS.Generation do end end + @typedoc "Result returned by `write_generated_file/3,4`." + @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()} 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 project = Projects.get_project!(project_id) @@ -335,6 +360,7 @@ defmodule BDS.Generation do end end + @spec list_generated_files(String.t()) :: {:ok, [map()]} def list_generated_files(project_id) when is_binary(project_id) do {:ok, Repo.all( @@ -344,6 +370,7 @@ defmodule BDS.Generation do )} end + @spec delete_generated_file(String.t(), String.t()) :: :ok | {:error, term()} def delete_generated_file(project_id, relative_path) when is_binary(project_id) and is_binary(relative_path) do project = Projects.get_project!(project_id) diff --git a/lib/bds/mcp.ex b/lib/bds/mcp.ex index eafa834..5843f2c 100644 --- a/lib/bds/mcp.ex +++ b/lib/bds/mcp.ex @@ -21,6 +21,13 @@ defmodule BDS.MCP do @page_size 50 @proposal_ttl_app_ms 30 * 60 * 1000 + @typedoc "Tool descriptor returned by `list_tools/0`." + @type tool_descriptor :: %{name: String.t(), annotations: map()} + + @typedoc "Resource descriptor returned by `list_resources/0`." + @type resource_descriptor :: %{name: String.t(), uri: String.t()} + + @spec list_tools() :: [tool_descriptor()] def list_tools do [ tool("check_term", true), @@ -37,6 +44,7 @@ defmodule BDS.MCP do ] end + @spec list_resources() :: [resource_descriptor()] def list_resources do [ %{name: "posts", uri: "bds://posts"}, @@ -46,6 +54,7 @@ defmodule BDS.MCP do ] end + @spec call_tool(String.t(), map()) :: {:ok, term()} | {:error, term()} def call_tool(name, params) when is_binary(name) and is_map(params) do ProposalStore.ensure_started() @@ -65,6 +74,7 @@ defmodule BDS.MCP do end end + @spec read_resource(String.t()) :: {:ok, term()} | {:error, term()} def read_resource(uri) when is_binary(uri) do ProposalStore.ensure_started() @@ -79,6 +89,7 @@ defmodule BDS.MCP do end end + @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: []}} diff --git a/lib/bds/media.ex b/lib/bds/media.ex index 5b40a3f..1d30d93 100644 --- a/lib/bds/media.ex +++ b/lib/bds/media.ex @@ -13,6 +13,13 @@ defmodule BDS.Media do alias BDS.Search alias BDS.Sidecar + @typedoc "An attribute map that may use atom or string keys." + @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} + + @typedoc "Options accepted by long-running rebuild operations." + @type rebuild_opts :: keyword() + + @spec import_media(attrs()) :: {:ok, Media.t()} | {:error, Ecto.Changeset.t() | term()} def import_media(attrs) do project = Projects.get_project!(attr(attrs, :project_id)) source_path = attr(attrs, :source_path) @@ -65,6 +72,8 @@ defmodule BDS.Media do end end + @spec update_media(String.t(), attrs()) :: + {:ok, Media.t()} | {:error, :not_found | Ecto.Changeset.t()} def update_media(media_id, attrs) do case Repo.get(Media, media_id) do nil -> @@ -102,6 +111,7 @@ defmodule BDS.Media do end end + @spec sync_media_sidecar(String.t()) :: :ok | {:error, :not_found | term()} def sync_media_sidecar(media_id) do case Repo.get(Media, media_id) do nil -> @@ -114,6 +124,8 @@ defmodule BDS.Media do end end + @spec sync_media_from_sidecar(String.t()) :: + {:ok, Media.t()} | {:error, :not_found | term()} def sync_media_from_sidecar(media_id) do case Repo.get(Media, media_id) do nil -> @@ -131,6 +143,8 @@ defmodule BDS.Media do end end + @spec sync_media_translation_sidecar(String.t()) :: + {:ok, Translation.t()} | {:error, :not_found | term()} def sync_media_translation_sidecar(translation_id) do case Repo.get(Translation, translation_id) do nil -> @@ -144,6 +158,8 @@ defmodule BDS.Media do end end + @spec sync_media_translation_from_sidecar(String.t()) :: + {:ok, Translation.t()} | {:error, :not_found | term()} def sync_media_translation_from_sidecar(translation_id) do case Repo.get(Translation, translation_id) do nil -> @@ -171,6 +187,8 @@ defmodule BDS.Media do end end + @spec import_orphan_media_sidecar(String.t(), String.t()) :: + {:ok, Media.t()} | {:error, term()} def import_orphan_media_sidecar(project_id, relative_path) do project = Projects.get_project!(project_id) sidecar_path = Path.join(Projects.project_data_dir(project), relative_path) @@ -182,6 +200,8 @@ defmodule BDS.Media do end end + @spec import_orphan_media_translation_sidecar(String.t(), String.t()) :: + {:ok, Translation.t()} | {:error, term()} def import_orphan_media_translation_sidecar(project_id, relative_path) do project = Projects.get_project!(project_id) sidecar_path = Path.join(Projects.project_data_dir(project), relative_path) @@ -214,6 +234,7 @@ defmodule BDS.Media do end end + @spec delete_media(String.t()) :: {:ok, :deleted} | {:error, :not_found} def delete_media(media_id) do case Repo.get(Media, media_id) do nil -> @@ -244,6 +265,8 @@ defmodule BDS.Media do end end + @spec upsert_media_translation(String.t(), String.t() | atom(), attrs()) :: + {:ok, Translation.t()} | {:error, :not_found | Ecto.Changeset.t()} def upsert_media_translation(media_id, language, attrs) do case Repo.get(Media, media_id) do nil -> @@ -286,6 +309,8 @@ defmodule BDS.Media do end end + @spec delete_media_translation(String.t(), String.t() | atom()) :: + {:ok, :deleted} | {:error, :not_found} def delete_media_translation(media_id, language) do normalized_language = language |> to_string() |> String.trim() |> String.downcase() @@ -316,6 +341,8 @@ defmodule BDS.Media do end end + @spec replace_media_file(String.t(), String.t()) :: + {:ok, Media.t() | nil} | {:error, :not_found | Ecto.Changeset.t() | term()} def replace_media_file(media_id, new_source_path) do case Repo.get(Media, media_id) do nil -> @@ -363,6 +390,7 @@ defmodule BDS.Media do end end + @spec list_media_translations(String.t()) :: [Translation.t()] def list_media_translations(media_id) when is_binary(media_id) do Repo.all( from translation in Translation, @@ -371,6 +399,7 @@ defmodule BDS.Media do ) end + @spec list_linked_posts(String.t()) :: [%{post_id: String.t(), title: String.t(), sort_order: integer()}] def list_linked_posts(media_id) when is_binary(media_id) do Repo.all( from post in BDS.Posts.Post, @@ -386,6 +415,8 @@ defmodule BDS.Media do ) end + @spec link_media_to_post(String.t(), String.t()) :: + {:ok, :linked} | {:error, :not_found | term()} def link_media_to_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do case {Repo.get(Media, media_id), Repo.get(BDS.Posts.Post, post_id)} do {nil, _post} -> @@ -424,6 +455,8 @@ defmodule BDS.Media do end end + @spec unlink_media_from_post(String.t(), String.t()) :: + {:ok, :unlinked} | {:error, :not_found | term()} def unlink_media_from_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do case Repo.get(Media, media_id) do nil -> @@ -444,6 +477,7 @@ defmodule BDS.Media do end end + @spec thumbnail_paths(Media.t()) :: %{required(atom()) => String.t()} def thumbnail_paths(%Media{id: id}) do prefix = String.slice(id, 0, 2) @@ -455,6 +489,7 @@ defmodule BDS.Media do } end + @spec regenerate_thumbnails(String.t()) :: {:ok, Media.t()} | {:error, :not_found | term()} def regenerate_thumbnails(media_id) do case Repo.get(Media, media_id) do nil -> @@ -467,6 +502,8 @@ defmodule BDS.Media do end end + @spec regenerate_missing_thumbnails(String.t(), rebuild_opts()) :: + %{processed: non_neg_integer(), generated: non_neg_integer(), failed: non_neg_integer()} def regenerate_missing_thumbnails(project_id, opts \\ []) do project = Projects.get_project!(project_id) on_progress = progress_callback(opts) @@ -518,6 +555,7 @@ defmodule BDS.Media do end) end + @spec rebuild_media_from_files(String.t(), rebuild_opts()) :: {:ok, [Media.t()]} def rebuild_media_from_files(project_id, opts \\ []) do project = Projects.get_project!(project_id) on_progress = progress_callback(opts) diff --git a/lib/bds/media/media.ex b/lib/bds/media/media.ex index 80ba16d..a4891d6 100644 --- a/lib/bds/media/media.ex +++ b/lib/bds/media/media.ex @@ -9,6 +9,29 @@ defmodule BDS.Media.Media do @primary_key {:id, :string, autogenerate: false} @foreign_key_type :string + @type t :: %__MODULE__{ + id: String.t() | nil, + project_id: String.t() | nil, + project: term(), + filename: String.t() | nil, + original_name: String.t() | nil, + mime_type: String.t() | nil, + size: integer() | nil, + width: integer() | nil, + height: integer() | nil, + title: String.t() | nil, + alt: String.t() | nil, + caption: String.t() | nil, + author: String.t() | nil, + language: String.t() | nil, + file_path: String.t() | nil, + sidecar_path: String.t() | nil, + checksum: String.t() | nil, + tags: [String.t()], + created_at: integer() | nil, + updated_at: integer() | nil + } + schema "media" do belongs_to :project, BDS.Projects.Project, type: :string @@ -31,6 +54,7 @@ defmodule BDS.Media.Media do field :updated_at, :integer end + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(media, attrs) do media |> cast( diff --git a/lib/bds/media/translation.ex b/lib/bds/media/translation.ex index 8b20e49..de71850 100644 --- a/lib/bds/media/translation.ex +++ b/lib/bds/media/translation.ex @@ -7,6 +7,19 @@ defmodule BDS.Media.Translation do @primary_key {:id, :string, autogenerate: false} @foreign_key_type :string + @type t :: %__MODULE__{ + id: String.t() | nil, + translation_for: String.t() | nil, + media: term(), + project_id: String.t() | nil, + language: String.t() | nil, + title: String.t() | nil, + alt: String.t() | nil, + caption: String.t() | nil, + created_at: integer() | nil, + updated_at: integer() | nil + } + schema "media_translations" do belongs_to :media, BDS.Media.Media, foreign_key: :translation_for, @@ -22,6 +35,7 @@ defmodule BDS.Media.Translation do field :updated_at, :integer end + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(translation, attrs) do translation |> cast( diff --git a/lib/bds/metadata.ex b/lib/bds/metadata.ex index 95cb7db..f3e54f0 100644 --- a/lib/bds/metadata.ex +++ b/lib/bds/metadata.ex @@ -36,16 +36,26 @@ defmodule BDS.Metadata do "zinc" ]) + @typedoc "Project metadata state map." + @type metadata_state :: map() + + @typedoc "Attribute map for `update_project_metadata/2`." + @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} + + @spec get_project_metadata(String.t()) :: {:ok, metadata_state()} def get_project_metadata(project_id) do project = Projects.get_project!(project_id) {:ok, load_state(project)} end + @spec read_project_metadata_from_filesystem(String.t()) :: {:ok, metadata_state()} def read_project_metadata_from_filesystem(project_id) do project = Projects.get_project!(project_id) {:ok, load_state_from_filesystem(project)} end + @spec update_project_metadata(String.t(), attrs()) :: + {:ok, metadata_state()} | {:error, term()} def update_project_metadata(project_id, attrs) do project = Projects.get_project!(project_id) state = load_state(project) @@ -85,6 +95,7 @@ defmodule BDS.Metadata do |> maybe_backfill_embeddings(project_id, state, project_metadata) end + @spec add_category(String.t(), String.t()) :: {:ok, metadata_state()} | {:error, term()} def add_category(project_id, name) do update_state(project_id, fn project, state, now -> categories = @@ -100,6 +111,7 @@ defmodule BDS.Metadata do end) end + @spec remove_category(String.t(), String.t()) :: {:ok, metadata_state()} | {:error, term()} def remove_category(project_id, name) do update_state(project_id, fn project, state, now -> categories = Enum.reject(state.categories, &(&1 == name)) @@ -113,6 +125,8 @@ defmodule BDS.Metadata do end) end + @spec update_category_settings(String.t(), String.t(), map()) :: + {:ok, metadata_state()} | {:error, term()} def update_category_settings(project_id, category, settings) do update_state(project_id, fn project, state, now -> normalized = normalize_category_settings(settings) @@ -124,6 +138,8 @@ defmodule BDS.Metadata do end) end + @spec set_publishing_preferences(String.t(), map()) :: + {:ok, metadata_state()} | {:error, term()} def set_publishing_preferences(project_id, prefs) do update_state(project_id, fn project, state, now -> publishing_preferences = normalize_publishing_preferences(prefs) @@ -133,6 +149,8 @@ defmodule BDS.Metadata do end) end + @spec sync_project_metadata_from_filesystem(String.t()) :: + {:ok, metadata_state()} | {:error, term()} def sync_project_metadata_from_filesystem(project_id) do project = Projects.get_project!(project_id) now = Persistence.now_ms() @@ -167,6 +185,7 @@ defmodule BDS.Metadata do |> unwrap_transaction() end + @spec flush_project_metadata_to_filesystem(String.t()) :: {:ok, metadata_state()} def flush_project_metadata_to_filesystem(project_id) do project = Projects.get_project!(project_id) state = load_state(project) diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index c37cca5..51028cd 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -21,6 +21,35 @@ defmodule BDS.Posts do alias BDS.Slug alias BDS.Tasks + @typedoc "An attribute map that may use atom or string keys." + @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} + + @typedoc "Options accepted by long-running rebuild operations." + @type rebuild_opts :: keyword() + + @typedoc "Aggregate counts returned by `dashboard_stats/1`." + @type dashboard_stats :: %{ + total_posts: non_neg_integer(), + draft_count: non_neg_integer(), + published_count: non_neg_integer(), + archived_count: non_neg_integer() + } + + @typedoc "Per-month post count entry returned by `post_counts_by_year_month/1`." + @type month_count :: %{year: integer(), month: integer(), count: non_neg_integer()} + + @typedoc "Translation validation report returned by `validate_translations/2`." + @type translation_validation_report :: %{ + checked_database_row_count: non_neg_integer(), + checked_filesystem_file_count: non_neg_integer(), + invalid_database_rows: [map()], + invalid_filesystem_files: [map()], + missing: [map()], + orphan_files: [map()], + do_not_translate_posts: [map()] + } + + @spec create_post(attrs()) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()} def create_post(attrs) do now = Persistence.now_ms() project_id = attr(attrs, :project_id) @@ -66,6 +95,8 @@ defmodule BDS.Posts do end end + @spec update_post(String.t(), attrs()) :: + {:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()} def update_post(post_id, attrs) do case Repo.get(Post, post_id) do nil -> @@ -107,6 +138,8 @@ defmodule BDS.Posts do end end + @spec publish_post(String.t()) :: + {:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()} def publish_post(post_id) do case Repo.get(Post, post_id) do nil -> @@ -149,6 +182,7 @@ defmodule BDS.Posts do end end + @spec rebuild_posts_from_files(String.t(), rebuild_opts()) :: {:ok, [Post.t()]} def rebuild_posts_from_files(project_id, opts \\ []) do project = Projects.get_project!(project_id) on_progress = progress_callback(opts) @@ -200,6 +234,8 @@ defmodule BDS.Posts do {:ok, posts} end + @spec discard_post_changes(String.t()) :: + {:ok, Post.t()} | {:error, :not_found} def discard_post_changes(post_id) do case Repo.get(Post, post_id) do nil -> @@ -222,6 +258,7 @@ defmodule BDS.Posts do end end + @spec editor_body(Post.t() | Translation.t() | term()) :: String.t() def editor_body(%Post{content: content}) when is_binary(content), do: content def editor_body(%Post{project_id: project_id, file_path: file_path}) @@ -246,6 +283,7 @@ defmodule BDS.Posts do def editor_body(_record), do: "" + @spec sync_post_from_file(String.t()) :: {:ok, Post.t()} | {:error, :not_found} def sync_post_from_file(post_id) do case Repo.get(Post, post_id) do nil -> @@ -268,6 +306,8 @@ defmodule BDS.Posts do end end + @spec sync_post_translation_from_file(String.t()) :: + {:ok, Translation.t()} | {:error, :not_found} def sync_post_translation_from_file(translation_id) do case Repo.get(Translation, translation_id) do nil -> @@ -289,6 +329,8 @@ defmodule BDS.Posts do end end + @spec rewrite_published_post_translation(String.t()) :: + {:ok, Translation.t()} | {:error, :not_found} def rewrite_published_post_translation(translation_id) do case Repo.get(Translation, translation_id) do nil -> @@ -305,6 +347,8 @@ defmodule BDS.Posts do end end + @spec import_orphan_post_file(String.t(), String.t()) :: + {:ok, Post.t()} | {:error, :not_found | :unsupported_file} def import_orphan_post_file(project_id, relative_path) do project = Projects.get_project!(project_id) full_path = Path.join(Projects.project_data_dir(project), relative_path) @@ -327,6 +371,8 @@ defmodule BDS.Posts do end end + @spec import_orphan_post_translation_file(String.t(), String.t()) :: + {:ok, Translation.t()} | {:error, :not_found | :unsupported_file | :conflict} def import_orphan_post_translation_file(project_id, relative_path) do project = Projects.get_project!(project_id) full_path = Path.join(Projects.project_data_dir(project), relative_path) @@ -359,6 +405,7 @@ defmodule BDS.Posts do end end + @spec delete_post(String.t()) :: {:ok, :deleted} | {:error, :not_found} def delete_post(post_id) do case Repo.get(Post, post_id) do nil -> @@ -376,6 +423,8 @@ defmodule BDS.Posts do end end + @spec archive_post(String.t()) :: + {:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()} def archive_post(post_id) do case Repo.get(Post, post_id) do nil -> @@ -402,10 +451,14 @@ defmodule BDS.Posts do end end + @spec get_post!(String.t()) :: Post.t() def get_post!(post_id), do: Repo.get!(Post, post_id) + @spec get_post_translation!(String.t()) :: Translation.t() def get_post_translation!(translation_id), do: Repo.get!(Translation, translation_id) + @spec publish_post_translation(String.t(), String.t() | atom()) :: + {:ok, Translation.t()} | {:error, :not_found | term()} def publish_post_translation(post_id, language) do normalized_language = language |> to_string() |> String.trim() |> String.downcase() @@ -424,6 +477,7 @@ defmodule BDS.Posts do end end + @spec slug_available(String.t(), String.t(), String.t() | nil) :: boolean() def slug_available(project_id, slug, exclude_post_id \\ nil) do normalized_slug = slug |> to_string() |> String.trim() @@ -441,6 +495,7 @@ defmodule BDS.Posts do end end + @spec unique_slug_for_title(String.t(), String.t(), String.t() | nil) :: String.t() def unique_slug_for_title(project_id, title, exclude_post_id \\ nil) do base_slug = title |> default_slug_source() |> Slug.slugify() @@ -455,6 +510,7 @@ defmodule BDS.Posts do end end + @spec dashboard_stats(String.t()) :: dashboard_stats() def dashboard_stats(project_id) do Repo.all( from(post in Post, @@ -477,6 +533,7 @@ defmodule BDS.Posts do ) end + @spec post_counts_by_year_month(String.t()) :: [month_count()] def post_counts_by_year_month(project_id) do Repo.all( from(post in Post, @@ -493,6 +550,7 @@ defmodule BDS.Posts do |> Enum.sort_by(fn %{year: year, month: month} -> {-year, -month} end) end + @spec rebuild_post_links(String.t(), rebuild_opts()) :: :ok def rebuild_post_links(project_id, opts \\ []) do post_ids = Repo.all(from(post in Post, where: post.project_id == ^project_id, select: post.id)) on_progress = progress_callback(opts) @@ -517,6 +575,7 @@ defmodule BDS.Posts do :ok end + @spec list_post_translations(String.t()) :: {:ok, [Translation.t()]} def list_post_translations(post_id) do {:ok, Repo.all( @@ -526,6 +585,8 @@ defmodule BDS.Posts do )} end + @spec upsert_post_translation(String.t(), String.t() | atom(), attrs()) :: + {:ok, Translation.t()} | {:error, :not_found | Ecto.Changeset.t()} def upsert_post_translation(post_id, language, attrs) do case Repo.get(Post, post_id) do nil -> @@ -566,6 +627,7 @@ defmodule BDS.Posts do end end + @spec delete_post_translation(String.t()) :: {:ok, :deleted} | {:error, :not_found} def delete_post_translation(translation_id) do case Repo.get(Translation, translation_id) do nil -> @@ -579,6 +641,7 @@ defmodule BDS.Posts do end end + @spec validate_translations(String.t(), rebuild_opts()) :: {:ok, translation_validation_report()} def validate_translations(project_id, opts \\ []) do project = Projects.get_project!(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id) @@ -657,6 +720,13 @@ defmodule BDS.Posts do }} end + @spec fix_invalid_translations(map()) :: + {:ok, + %{ + deleted_database_rows: non_neg_integer(), + deleted_files: non_neg_integer(), + flushed_translations: non_neg_integer() + }} def fix_invalid_translations(report) when is_map(report) do normalized_report = normalize_translation_validation_report(report) @@ -693,6 +763,7 @@ defmodule BDS.Posts do }} end + @spec rewrite_published_post(String.t()) :: :ok def rewrite_published_post(post_id) do post = Repo.get!(Post, post_id) @@ -1256,7 +1327,6 @@ defmodule BDS.Posts do %{post_id: post.id, translation_id: saved_translation.id, language: language} else {:error, reason} -> {:error, reason} - other -> {:error, other} end end, auto_translation_task_attrs(post) @@ -1294,7 +1364,6 @@ defmodule BDS.Posts do %{media_id: media_id, translation_id: saved_translation.id, language: language} else {:error, reason} -> {:error, reason} - other -> {:error, other} end end, auto_translation_task_attrs(post) diff --git a/lib/bds/posts/link.ex b/lib/bds/posts/link.ex index 9445216..956d030 100644 --- a/lib/bds/posts/link.ex +++ b/lib/bds/posts/link.ex @@ -7,6 +7,16 @@ defmodule BDS.Posts.Link do @primary_key {:id, :string, autogenerate: false} @foreign_key_type :string + @type t :: %__MODULE__{ + id: String.t() | nil, + source_post_id: String.t() | nil, + target_post_id: String.t() | nil, + source_post: term(), + target_post: term(), + link_text: String.t() | nil, + created_at: integer() | nil + } + 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 @@ -15,6 +25,7 @@ defmodule BDS.Posts.Link do field :created_at, :integer end + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(link, attrs) do link |> cast(attrs, [:id, :source_post_id, :target_post_id, :link_text, :created_at]) diff --git a/lib/bds/posts/post.ex b/lib/bds/posts/post.ex index 4c3c44f..7220f05 100644 --- a/lib/bds/posts/post.ex +++ b/lib/bds/posts/post.ex @@ -10,6 +10,35 @@ defmodule BDS.Posts.Post do @foreign_key_type :string @statuses [:draft, :published, :archived] + @type status :: :draft | :published | :archived + + @type t :: %__MODULE__{ + id: String.t() | nil, + project_id: String.t() | nil, + project: term(), + title: String.t() | nil, + slug: String.t() | nil, + excerpt: String.t() | nil, + content: String.t() | nil, + status: status(), + author: String.t() | nil, + created_at: integer() | nil, + updated_at: integer() | nil, + published_at: integer() | nil, + file_path: String.t(), + checksum: String.t() | nil, + tags: [String.t()], + categories: [String.t()], + template_slug: String.t() | nil, + language: String.t() | nil, + do_not_translate: boolean(), + published_title: String.t() | nil, + published_content: String.t() | nil, + published_tags: String.t() | nil, + published_categories: String.t() | nil, + published_excerpt: String.t() | nil + } + schema "posts" do belongs_to :project, BDS.Projects.Project, type: :string @@ -36,6 +65,7 @@ defmodule BDS.Posts.Post do field :published_excerpt, :string end + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(post, attrs) do post |> cast( diff --git a/lib/bds/posts/translation.ex b/lib/bds/posts/translation.ex index 443b1fe..bc58894 100644 --- a/lib/bds/posts/translation.ex +++ b/lib/bds/posts/translation.ex @@ -8,6 +8,25 @@ defmodule BDS.Posts.Translation do @foreign_key_type :string @statuses [:draft, :published] + @type status :: :draft | :published + + @type t :: %__MODULE__{ + id: String.t() | nil, + translation_for: String.t() | nil, + post: term(), + project_id: String.t() | nil, + language: String.t() | nil, + title: String.t() | nil, + excerpt: String.t() | nil, + content: String.t() | nil, + status: status(), + created_at: integer() | nil, + updated_at: integer() | nil, + published_at: integer() | nil, + file_path: String.t(), + checksum: String.t() | nil + } + schema "post_translations" do belongs_to :post, BDS.Posts.Post, foreign_key: :translation_for, @@ -27,6 +46,7 @@ defmodule BDS.Posts.Translation do field :checksum, :string end + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(translation, attrs) do translation |> cast( diff --git a/lib/bds/projects.ex b/lib/bds/projects.ex index 7872a53..cd264a5 100644 --- a/lib/bds/projects.ex +++ b/lib/bds/projects.ex @@ -13,14 +13,35 @@ defmodule BDS.Projects do @default_project_id "default" @default_project_name "My Blog" + @typedoc "An attribute map that may use atom or string keys." + @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} + + @typedoc "Summary map returned for the shell projects panel." + @type project_summary :: %{ + id: String.t(), + name: String.t(), + slug: String.t(), + data_path: String.t() | nil, + is_active: boolean() + } + + @typedoc "Snapshot returned to the desktop shell." + @type shell_snapshot :: %{ + active_project_id: String.t() | nil, + projects: [project_summary()] + } + + @spec list_projects() :: [Project.t()] def list_projects do Repo.all(from project in Project, order_by: [asc: project.created_at]) end + @spec get_active_project() :: Project.t() | nil def get_active_project do Repo.one(from project in Project, where: project.is_active == true, limit: 1) end + @spec shell_snapshot() :: shell_snapshot() def shell_snapshot do _ = ensure_default_project() projects = list_projects() @@ -32,9 +53,13 @@ defmodule BDS.Projects do } end + @spec get_project(String.t()) :: Project.t() | nil def get_project(id), do: Repo.get(Project, id) + + @spec get_project!(String.t()) :: Project.t() def get_project!(id), do: Repo.get!(Project, id) + @spec ensure_default_project() :: {:ok, Project.t()} | {:error, term()} def ensure_default_project do case Repo.get(Project, @default_project_id) do %Project{} = project -> @@ -69,16 +94,19 @@ defmodule BDS.Projects do end end + @spec project_data_dir(Project.t()) :: String.t() def project_data_dir(%Project{} = project) do project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__) end + @spec project_cache_dir(Project.t() | String.t()) :: String.t() def project_cache_dir(%Project{} = project), do: project_cache_dir(project.id) def project_cache_dir(project_id) when is_binary(project_id) do Path.join([project_cache_root(), "projects", project_id]) end + @spec create_project(attrs()) :: {:ok, Project.t()} | {:error, term()} def create_project(attrs) do now = Persistence.now_ms() name = attr(attrs, :name) || "" @@ -108,6 +136,7 @@ defmodule BDS.Projects do end end + @spec set_active_project(String.t()) :: {:ok, Project.t()} | {:error, :not_found | term()} def set_active_project(project_id) do case Repo.get(Project, project_id) do nil -> @@ -133,6 +162,9 @@ defmodule BDS.Projects do end end + @spec delete_project(String.t()) :: + {:ok, Project.t()} + | {: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 -> diff --git a/lib/bds/projects/project.ex b/lib/bds/projects/project.ex index f16ddad..16d8dbe 100644 --- a/lib/bds/projects/project.ex +++ b/lib/bds/projects/project.ex @@ -7,6 +7,18 @@ defmodule BDS.Projects.Project do @primary_key {:id, :string, autogenerate: false} @foreign_key_type :string + @type t :: %__MODULE__{ + id: String.t() | nil, + name: String.t() | nil, + slug: String.t() | nil, + description: String.t() | nil, + data_path: String.t() | nil, + created_at: integer() | nil, + updated_at: integer() | nil, + is_active: boolean(), + posts: term() + } + schema "projects" do field :name, :string field :slug, :string @@ -19,6 +31,7 @@ defmodule BDS.Projects.Project do has_many :posts, BDS.Posts.Post, foreign_key: :project_id end + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(project, attrs) do project |> cast( diff --git a/lib/bds/publishing.ex b/lib/bds/publishing.ex index 3c00dde..6c06527 100644 --- a/lib/bds/publishing.ex +++ b/lib/bds/publishing.ex @@ -9,10 +9,15 @@ defmodule BDS.Publishing do alias BDS.Repo alias BDS.Tasks + @typedoc "Credentials map for an upload destination." + @type credentials :: map() + + @spec start_link(term()) :: GenServer.on_start() def start_link(_opts) do GenServer.start_link(__MODULE__, %{}, name: __MODULE__) end + @spec upload_site(String.t(), credentials(), keyword()) :: {:ok, String.t()} | {:error, term()} def upload_site(project_id, credentials, opts \\ []) when is_binary(project_id) and is_map(credentials) and is_list(opts) do project = Projects.get_project!(project_id) @@ -21,6 +26,7 @@ defmodule BDS.Publishing do GenServer.call(__MODULE__, {:upload_site, project_id, normalized_credentials, targets, opts}) end + @spec get_job(String.t()) :: PublishJob.t() | nil def get_job(job_id) when is_binary(job_id) do GenServer.call(__MODULE__, {:get_job, job_id}) end diff --git a/lib/bds/publishing/publish_job.ex b/lib/bds/publishing/publish_job.ex index 5255267..d97755d 100644 --- a/lib/bds/publishing/publish_job.ex +++ b/lib/bds/publishing/publish_job.ex @@ -7,6 +7,24 @@ defmodule BDS.Publishing.PublishJob do @primary_key {:id, :string, autogenerate: false} @foreign_key_type :string + @type ssh_mode :: :scp | :rsync + @type status :: :pending | :running | :completed | :failed + + @type t :: %__MODULE__{ + id: String.t() | nil, + project_id: String.t() | nil, + ssh_host: String.t() | nil, + ssh_user: String.t() | nil, + ssh_remote_path: String.t() | nil, + ssh_mode: ssh_mode(), + status: status(), + task_id: String.t() | nil, + targets: [String.t()], + error: String.t() | nil, + inserted_at: integer() | nil, + updated_at: integer() | nil + } + schema "publish_jobs" do field :project_id, :string field :ssh_host, :string @@ -21,6 +39,7 @@ defmodule BDS.Publishing.PublishJob do field :updated_at, :integer end + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(job, attrs) do job |> cast( diff --git a/lib/bds/search.ex b/lib/bds/search.ex index 5368ee8..5f679de 100644 --- a/lib/bds/search.ex +++ b/lib/bds/search.ex @@ -62,10 +62,18 @@ defmodule BDS.Search do {"it", ~w(il lo la gli le un una uno e che per con ogni mattina)} ] + @typedoc "Filters and pagination accepted by the search functions." + @type search_filters :: %{optional(atom()) => term(), optional(String.t()) => term()} + + @typedoc "Reindex/long-running progress options." + @type reindex_opts :: keyword() + + @spec list_stemmer_languages() :: [String.t()] def list_stemmer_languages do @stemmer_languages end + @spec detect_language(String.t() | nil) :: String.t() def detect_language(text) do normalized_text = text |> to_string() |> String.downcase() @@ -78,6 +86,7 @@ defmodule BDS.Search do end end + @spec stem(String.t() | nil, String.t() | nil) :: String.t() def stem(text, language \\ nil) do language = normalize_language(language || detect_language(text)) @@ -86,6 +95,14 @@ defmodule BDS.Search do |> Enum.map_join(" ", &stem_token(&1, language)) end + @spec search_posts(String.t(), String.t() | nil, search_filters()) :: + {:ok, + %{ + posts: [Post.t()], + total: non_neg_integer(), + offset: non_neg_integer(), + limit: non_neg_integer() + }} def search_posts(project_id, query, filters \\ %{}) do filters = normalize_filters(filters) @@ -104,6 +121,14 @@ defmodule BDS.Search do }} end + @spec search_media(String.t(), String.t() | nil, search_filters()) :: + {:ok, + %{ + media: [Media.t()], + total: non_neg_integer(), + offset: non_neg_integer(), + limit: non_neg_integer() + }} def search_media(project_id, query, filters \\ %{}) do filters = normalize_filters(filters) @@ -121,6 +146,7 @@ defmodule BDS.Search do }} end + @spec reindex_project(String.t()) :: :ok def reindex_project(project_id) do :ok = reindex_posts(project_id) :ok = reindex_media(project_id) @@ -128,6 +154,7 @@ defmodule BDS.Search do :ok end + @spec reindex_posts(String.t(), reindex_opts()) :: :ok def reindex_posts(project_id, opts \\ []) do Repo.query!( "DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)", @@ -150,6 +177,7 @@ defmodule BDS.Search do :ok end + @spec reindex_media(String.t(), reindex_opts()) :: :ok def reindex_media(project_id, opts \\ []) do Repo.query!( "DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)", @@ -172,6 +200,7 @@ defmodule BDS.Search do :ok end + @spec sync_post(Post.t() | String.t()) :: :ok def sync_post(%Post{} = post) do delete_post(post.id) insert_post_index(post) @@ -185,6 +214,7 @@ defmodule BDS.Search do end end + @spec delete_post(Post.t() | String.t()) :: :ok def delete_post(%Post{id: post_id}), do: delete_post(post_id) def delete_post(post_id) when is_binary(post_id) do @@ -192,6 +222,7 @@ defmodule BDS.Search do :ok end + @spec sync_media(Media.t() | String.t()) :: :ok def sync_media(%Media{} = media) do delete_media(media.id) insert_media_index(media) @@ -205,6 +236,7 @@ defmodule BDS.Search do end end + @spec delete_media(Media.t() | String.t()) :: :ok def delete_media(%Media{id: media_id}), do: delete_media(media_id) def delete_media(media_id) when is_binary(media_id) do