chore: working on code smells
This commit is contained in:
120
CODESMELL.md
120
CODESMELL.md
@@ -1,6 +1,8 @@
|
|||||||
# Elixir Code Smell Analysis — bDS2
|
# Elixir Code Smell Analysis — bDS2
|
||||||
|
|
||||||
> Generated by static analysis of the codebase. Do not delete; use as a reference for refactoring sprints.
|
> 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 |
|
| 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.Desktop.ShellLive` | 2,607 | 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.Posts` | 1,709 | 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.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.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.
|
**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
|
### 2. Process Dictionary for I18n State
|
||||||
|
(and 13 other call sites across all `*_editor.ex` modules — 15 total)
|
||||||
In `BDS.Desktop.ShellLive`:
|
In `BDS.Desktop.ShellLive`:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
@@ -53,7 +56,7 @@ defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings,
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 3. Side Effects Inside Database Transactions
|
### 3. Side Effects Inside Database Transactions
|
||||||
|
(7 transactions in `lib/bds/media.ex` follow this pattern)
|
||||||
In `BDS.Media.import_media/1`:
|
In `BDS.Media.import_media/1`:
|
||||||
|
|
||||||
```elixir
|
```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.
|
**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.
|
**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
|
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.
|
**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:
|
`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`:
|
In `BDS.AI.OpenAICompatibleRuntime`:
|
||||||
|
|
||||||
@@ -186,9 +191,7 @@ defp normalize_response(body) do
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why this is an anti-pattern:** Malformed JSON from an external API will crash the caller.
|
**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.
|
||||||
|
|
||||||
**Fix:** Use `Jason.decode/1` and propagate `{:error, reason}`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -196,14 +199,19 @@ end
|
|||||||
|
|
||||||
### 11. Missing `@spec` Typespecs
|
### 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
|
### 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
|
### 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.
|
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
|
### 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.
|
- **`with` blocks** handle multi-step error flows cleanly.
|
||||||
- **Pattern matching** is idiomatic and pervasive.
|
- **Pattern matching** is idiomatic and pervasive.
|
||||||
- **Task.Supervisor** is used correctly for background work.
|
- **Task.Supervisor** is used correctly for background work.
|
||||||
- **Atomic file writes** (`Persistence.atomic_write`) show good filesystem hygiene.
|
- *Verification Summary (2026-04-30)
|
||||||
- **PubSub** is used appropriately for CLI sync notifications.
|
|
||||||
|
| # | 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ defmodule BDS.AI do
|
|||||||
airplane_image_analysis: "ai.airplane.model.image_analysis"
|
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
|
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)
|
backend = Keyword.get(opts, :secret_backend, SecretBackend)
|
||||||
kind_key = Atom.to_string(kind)
|
kind_key = Atom.to_string(kind)
|
||||||
@@ -49,6 +60,8 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
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
|
def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do
|
||||||
backend = Keyword.get(opts, :secret_backend, SecretBackend)
|
backend = Keyword.get(opts, :secret_backend, SecretBackend)
|
||||||
kind_key = Atom.to_string(kind)
|
kind_key = Atom.to_string(kind)
|
||||||
@@ -67,6 +80,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec delete_endpoint(endpoint_kind()) :: :ok
|
||||||
def delete_endpoint(kind) when is_atom(kind) do
|
def delete_endpoint(kind) when is_atom(kind) do
|
||||||
kind_key = Atom.to_string(kind)
|
kind_key = Atom.to_string(kind)
|
||||||
delete_setting("ai.#{kind_key}.url")
|
delete_setting("ai.#{kind_key}.url")
|
||||||
@@ -75,11 +89,15 @@ defmodule BDS.AI do
|
|||||||
:ok
|
:ok
|
||||||
end
|
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
|
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))
|
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)
|
OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client)
|
||||||
end
|
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
|
def refresh_model_catalog(opts \\ []) when is_list(opts) do
|
||||||
http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient)
|
http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient)
|
||||||
|
|
||||||
@@ -111,6 +129,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec list_catalog_providers() :: [map()]
|
||||||
def list_catalog_providers do
|
def list_catalog_providers do
|
||||||
Repo.all(from provider in CatalogProvider, order_by: [asc: provider.id])
|
Repo.all(from provider in CatalogProvider, order_by: [asc: provider.id])
|
||||||
|> Enum.map(fn provider ->
|
|> Enum.map(fn provider ->
|
||||||
@@ -126,6 +145,7 @@ defmodule BDS.AI do
|
|||||||
end)
|
end)
|
||||||
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
|
def get_catalog_model(model_id, provider_id \\ nil) when is_binary(model_id) do
|
||||||
query =
|
query =
|
||||||
from model in Model,
|
from model in Model,
|
||||||
@@ -144,14 +164,17 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec catalog_meta(String.t()) :: {:ok, String.t() | nil}
|
||||||
def catalog_meta(key) when is_binary(key) do
|
def catalog_meta(key) when is_binary(key) do
|
||||||
{:ok, get_catalog_meta_value(key)}
|
{:ok, get_catalog_meta_value(key)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec set_airplane_mode(boolean()) :: :ok | {:error, term()}
|
||||||
def set_airplane_mode(enabled) when is_boolean(enabled) do
|
def set_airplane_mode(enabled) when is_boolean(enabled) do
|
||||||
put_setting("ai.airplane_mode_enabled", Atom.to_string(enabled))
|
put_setting("ai.airplane_mode_enabled", Atom.to_string(enabled))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec airplane_mode?(boolean()) :: boolean()
|
||||||
def airplane_mode?(default \\ false) when is_boolean(default) do
|
def airplane_mode?(default \\ false) when is_boolean(default) do
|
||||||
case get_setting("ai.airplane_mode_enabled") do
|
case get_setting("ai.airplane_mode_enabled") do
|
||||||
nil -> default
|
nil -> default
|
||||||
@@ -160,6 +183,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
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
|
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
|
||||||
case Map.fetch(@model_preference_keys, key) do
|
case Map.fetch(@model_preference_keys, key) do
|
||||||
{:ok, setting_key} -> put_setting(setting_key, model)
|
{:ok, setting_key} -> put_setting(setting_key, model)
|
||||||
@@ -167,6 +191,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
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
|
def get_model_preference(key) when is_atom(key) do
|
||||||
case Map.fetch(@model_preference_keys, key) do
|
case Map.fetch(@model_preference_keys, key) do
|
||||||
{:ok, setting_key} -> {:ok, get_setting(setting_key)}
|
{:ok, setting_key} -> {:ok, get_setting(setting_key)}
|
||||||
@@ -174,6 +199,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
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
|
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
|
||||||
capabilities = %{
|
capabilities = %{
|
||||||
supports_attachment: truthy?(Map.get(attrs, :supports_attachment) || Map.get(attrs, "supports_attachment")),
|
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))
|
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
|
||||||
end
|
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
|
def detect_language(text, opts \\ []) when is_binary(text) and is_list(opts) do
|
||||||
run_one_shot(
|
run_one_shot(
|
||||||
:detect_language,
|
:detect_language,
|
||||||
@@ -194,6 +221,7 @@ defmodule BDS.AI do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec analyze_taxonomy(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
def analyze_taxonomy(post_input, opts \\ []) when is_list(opts) do
|
def analyze_taxonomy(post_input, opts \\ []) when is_list(opts) do
|
||||||
with {:ok, post} <- normalize_post_input(post_input) do
|
with {:ok, post} <- normalize_post_input(post_input) do
|
||||||
run_one_shot(
|
run_one_shot(
|
||||||
@@ -212,6 +240,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec analyze_import_taxonomy(map(), map(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
def analyze_import_taxonomy(import_terms, existing_terms, opts \\ [])
|
def analyze_import_taxonomy(import_terms, existing_terms, opts \\ [])
|
||||||
when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do
|
when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do
|
||||||
payload = %{
|
payload = %{
|
||||||
@@ -246,6 +275,7 @@ defmodule BDS.AI do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec analyze_post(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
def analyze_post(post_input, opts \\ []) when is_list(opts) do
|
def analyze_post(post_input, opts \\ []) when is_list(opts) do
|
||||||
with {:ok, post} <- normalize_post_input(post_input) do
|
with {:ok, post} <- normalize_post_input(post_input) do
|
||||||
run_one_shot(
|
run_one_shot(
|
||||||
@@ -265,6 +295,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec translate_post(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
def translate_post(post_input, target_language, opts \\ [])
|
def translate_post(post_input, target_language, opts \\ [])
|
||||||
when is_binary(target_language) and is_list(opts) do
|
when is_binary(target_language) and is_list(opts) do
|
||||||
with {:ok, post} <- normalize_post_input(post_input) do
|
with {:ok, post} <- normalize_post_input(post_input) do
|
||||||
@@ -285,6 +316,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
def analyze_image(media_input, opts \\ []) when is_list(opts) do
|
def analyze_image(media_input, opts \\ []) when is_list(opts) do
|
||||||
with {:ok, media} <- normalize_media_input(media_input),
|
with {:ok, media} <- normalize_media_input(media_input),
|
||||||
:ok <- ensure_image_media(media) do
|
:ok <- ensure_image_media(media) do
|
||||||
@@ -305,6 +337,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec translate_media(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
def translate_media(media_input, target_language, opts \\ [])
|
def translate_media(media_input, target_language, opts \\ [])
|
||||||
when is_binary(target_language) and is_list(opts) do
|
when is_binary(target_language) and is_list(opts) do
|
||||||
with {:ok, media} <- normalize_media_input(media_input) do
|
with {:ok, media} <- normalize_media_input(media_input) do
|
||||||
@@ -325,6 +358,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
|
||||||
def start_chat(attrs \\ %{}) when is_map(attrs) do
|
def start_chat(attrs \\ %{}) when is_map(attrs) do
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
model = Map.get(attrs, :model) || Map.get(attrs, "model")
|
model = Map.get(attrs, :model) || Map.get(attrs, "model")
|
||||||
@@ -346,11 +380,13 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec list_chat_conversations() :: [map()]
|
||||||
def list_chat_conversations do
|
def list_chat_conversations do
|
||||||
Repo.all(from conversation in ChatConversation, order_by: [desc: conversation.updated_at])
|
Repo.all(from conversation in ChatConversation, order_by: [desc: conversation.updated_at])
|
||||||
|> Enum.map(&format_conversation/1)
|
|> Enum.map(&format_conversation/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec available_chat_models(String.t() | nil) :: [map()]
|
||||||
def available_chat_models(current_model \\ nil) do
|
def available_chat_models(current_model \\ nil) do
|
||||||
endpoint_models = configured_chat_models()
|
endpoint_models = configured_chat_models()
|
||||||
|
|
||||||
@@ -378,6 +414,8 @@ defmodule BDS.AI do
|
|||||||
end)
|
end)
|
||||||
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)
|
def set_conversation_model(conversation_id, model_id)
|
||||||
when is_binary(conversation_id) and is_binary(model_id) do
|
when is_binary(conversation_id) and is_binary(model_id) do
|
||||||
case Repo.get(ChatConversation, conversation_id) do
|
case Repo.get(ChatConversation, conversation_id) do
|
||||||
@@ -395,6 +433,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec list_chat_messages(String.t()) :: [map()]
|
||||||
def list_chat_messages(conversation_id) when is_binary(conversation_id) do
|
def list_chat_messages(conversation_id) when is_binary(conversation_id) do
|
||||||
Repo.all(
|
Repo.all(
|
||||||
from message in ChatMessage,
|
from message in ChatMessage,
|
||||||
@@ -404,6 +443,8 @@ defmodule BDS.AI do
|
|||||||
|> Enum.map(&format_chat_message/1)
|
|> Enum.map(&format_chat_message/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec send_chat_message(String.t(), String.t(), keyword()) ::
|
||||||
|
{:ok, map()} | {:error, :not_found | term()}
|
||||||
def send_chat_message(conversation_id, content, opts \\ [])
|
def send_chat_message(conversation_id, content, opts \\ [])
|
||||||
when is_binary(conversation_id) and is_binary(content) and is_list(opts) do
|
when is_binary(conversation_id) and is_binary(content) and is_list(opts) do
|
||||||
with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id),
|
with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id),
|
||||||
@@ -437,6 +478,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec cancel_chat(String.t()) :: :ok
|
||||||
def cancel_chat(conversation_id) when is_binary(conversation_id) do
|
def cancel_chat(conversation_id) when is_binary(conversation_id) do
|
||||||
case InFlight.lookup(conversation_id) do
|
case InFlight.lookup(conversation_id) do
|
||||||
nil -> :ok
|
nil -> :ok
|
||||||
|
|||||||
@@ -915,7 +915,6 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|
|
||||||
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
|
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
|
||||||
defp blank?(nil), do: true
|
defp blank?(nil), do: true
|
||||||
defp blank?(_value), do: false
|
|
||||||
|
|
||||||
defp rewrite_external_images(html) do
|
defp rewrite_external_images(html) do
|
||||||
html =
|
html =
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ defmodule BDS.Generation do
|
|||||||
|
|
||||||
@core_sections [:core, :single, :category, :tag, :date]
|
@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])
|
def plan_generation(project_id, sections \\ [:core])
|
||||||
when is_binary(project_id) and is_list(sections) do
|
when is_binary(project_id) and is_list(sections) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
@@ -40,6 +53,8 @@ defmodule BDS.Generation do
|
|||||||
}}
|
}}
|
||||||
end
|
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 \\ [:core], opts \\ [])
|
||||||
|
|
||||||
def generate_site(project_id, sections, opts)
|
def generate_site(project_id, sections, opts)
|
||||||
@@ -63,6 +78,8 @@ defmodule BDS.Generation do
|
|||||||
end
|
end
|
||||||
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 \\ @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
|
||||||
@@ -189,6 +206,7 @@ defmodule BDS.Generation do
|
|||||||
:ok
|
:ok
|
||||||
end
|
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
|
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
|
with {:ok, plan} <- plan_generation(project_id, sections) do
|
||||||
expected_outputs = build_outputs(plan)
|
expected_outputs = build_outputs(plan)
|
||||||
@@ -283,8 +301,10 @@ defmodule BDS.Generation do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec post_output_path(map()) :: String.t()
|
||||||
def post_output_path(post), do: post_output_path(post, nil)
|
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
|
def post_output_path(post, language) when is_map(post) do
|
||||||
{year, month, day} = local_date_parts!(post.created_at)
|
{year, month, day} = local_date_parts!(post.created_at)
|
||||||
year = Integer.to_string(year)
|
year = Integer.to_string(year)
|
||||||
@@ -300,9 +320,14 @@ defmodule BDS.Generation do
|
|||||||
end
|
end
|
||||||
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),
|
def write_generated_file(project_id, relative_path, content),
|
||||||
do: 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)
|
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)
|
project = Projects.get_project!(project_id)
|
||||||
@@ -335,6 +360,7 @@ defmodule BDS.Generation do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec list_generated_files(String.t()) :: {:ok, [map()]}
|
||||||
def list_generated_files(project_id) when is_binary(project_id) do
|
def list_generated_files(project_id) when is_binary(project_id) do
|
||||||
{:ok,
|
{:ok,
|
||||||
Repo.all(
|
Repo.all(
|
||||||
@@ -344,6 +370,7 @@ defmodule BDS.Generation do
|
|||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec delete_generated_file(String.t(), String.t()) :: :ok | {:error, term()}
|
||||||
def delete_generated_file(project_id, relative_path)
|
def delete_generated_file(project_id, relative_path)
|
||||||
when is_binary(project_id) and is_binary(relative_path) do
|
when is_binary(project_id) and is_binary(relative_path) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ defmodule BDS.MCP do
|
|||||||
@page_size 50
|
@page_size 50
|
||||||
@proposal_ttl_app_ms 30 * 60 * 1000
|
@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
|
def list_tools do
|
||||||
[
|
[
|
||||||
tool("check_term", true),
|
tool("check_term", true),
|
||||||
@@ -37,6 +44,7 @@ defmodule BDS.MCP do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec list_resources() :: [resource_descriptor()]
|
||||||
def list_resources do
|
def list_resources do
|
||||||
[
|
[
|
||||||
%{name: "posts", uri: "bds://posts"},
|
%{name: "posts", uri: "bds://posts"},
|
||||||
@@ -46,6 +54,7 @@ defmodule BDS.MCP do
|
|||||||
]
|
]
|
||||||
end
|
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
|
def call_tool(name, params) when is_binary(name) and is_map(params) do
|
||||||
ProposalStore.ensure_started()
|
ProposalStore.ensure_started()
|
||||||
|
|
||||||
@@ -65,6 +74,7 @@ defmodule BDS.MCP do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec read_resource(String.t()) :: {:ok, term()} | {:error, term()}
|
||||||
def read_resource(uri) when is_binary(uri) do
|
def read_resource(uri) when is_binary(uri) do
|
||||||
ProposalStore.ensure_started()
|
ProposalStore.ensure_started()
|
||||||
|
|
||||||
@@ -79,6 +89,7 @@ defmodule BDS.MCP do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
|
||||||
def validate_template(source) when is_binary(source) do
|
def validate_template(source) when is_binary(source) do
|
||||||
case Liquex.parse(source) do
|
case Liquex.parse(source) do
|
||||||
{:ok, _ast} -> {:ok, %{valid: true, errors: []}}
|
{:ok, _ast} -> {:ok, %{valid: true, errors: []}}
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ defmodule BDS.Media do
|
|||||||
alias BDS.Search
|
alias BDS.Search
|
||||||
alias BDS.Sidecar
|
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
|
def import_media(attrs) do
|
||||||
project = Projects.get_project!(attr(attrs, :project_id))
|
project = Projects.get_project!(attr(attrs, :project_id))
|
||||||
source_path = attr(attrs, :source_path)
|
source_path = attr(attrs, :source_path)
|
||||||
@@ -65,6 +72,8 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec update_media(String.t(), attrs()) ::
|
||||||
|
{:ok, Media.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def update_media(media_id, attrs) do
|
def update_media(media_id, attrs) do
|
||||||
case Repo.get(Media, media_id) do
|
case Repo.get(Media, media_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -102,6 +111,7 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec sync_media_sidecar(String.t()) :: :ok | {:error, :not_found | term()}
|
||||||
def sync_media_sidecar(media_id) do
|
def sync_media_sidecar(media_id) do
|
||||||
case Repo.get(Media, media_id) do
|
case Repo.get(Media, media_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -114,6 +124,8 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec sync_media_from_sidecar(String.t()) ::
|
||||||
|
{:ok, Media.t()} | {:error, :not_found | term()}
|
||||||
def sync_media_from_sidecar(media_id) do
|
def sync_media_from_sidecar(media_id) do
|
||||||
case Repo.get(Media, media_id) do
|
case Repo.get(Media, media_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -131,6 +143,8 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec sync_media_translation_sidecar(String.t()) ::
|
||||||
|
{:ok, Translation.t()} | {:error, :not_found | term()}
|
||||||
def sync_media_translation_sidecar(translation_id) do
|
def sync_media_translation_sidecar(translation_id) do
|
||||||
case Repo.get(Translation, translation_id) do
|
case Repo.get(Translation, translation_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -144,6 +158,8 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
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
|
def sync_media_translation_from_sidecar(translation_id) do
|
||||||
case Repo.get(Translation, translation_id) do
|
case Repo.get(Translation, translation_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -171,6 +187,8 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
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
|
def import_orphan_media_sidecar(project_id, relative_path) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
sidecar_path = Path.join(Projects.project_data_dir(project), relative_path)
|
sidecar_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||||
@@ -182,6 +200,8 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
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
|
def import_orphan_media_translation_sidecar(project_id, relative_path) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
sidecar_path = Path.join(Projects.project_data_dir(project), relative_path)
|
sidecar_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||||
@@ -214,6 +234,7 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec delete_media(String.t()) :: {:ok, :deleted} | {:error, :not_found}
|
||||||
def delete_media(media_id) do
|
def delete_media(media_id) do
|
||||||
case Repo.get(Media, media_id) do
|
case Repo.get(Media, media_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -244,6 +265,8 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
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
|
def upsert_media_translation(media_id, language, attrs) do
|
||||||
case Repo.get(Media, media_id) do
|
case Repo.get(Media, media_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -286,6 +309,8 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec delete_media_translation(String.t(), String.t() | atom()) ::
|
||||||
|
{:ok, :deleted} | {:error, :not_found}
|
||||||
def delete_media_translation(media_id, language) do
|
def delete_media_translation(media_id, language) do
|
||||||
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
||||||
|
|
||||||
@@ -316,6 +341,8 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
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
|
def replace_media_file(media_id, new_source_path) do
|
||||||
case Repo.get(Media, media_id) do
|
case Repo.get(Media, media_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -363,6 +390,7 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec list_media_translations(String.t()) :: [Translation.t()]
|
||||||
def list_media_translations(media_id) when is_binary(media_id) do
|
def list_media_translations(media_id) when is_binary(media_id) do
|
||||||
Repo.all(
|
Repo.all(
|
||||||
from translation in Translation,
|
from translation in Translation,
|
||||||
@@ -371,6 +399,7 @@ defmodule BDS.Media do
|
|||||||
)
|
)
|
||||||
end
|
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
|
def list_linked_posts(media_id) when is_binary(media_id) do
|
||||||
Repo.all(
|
Repo.all(
|
||||||
from post in BDS.Posts.Post,
|
from post in BDS.Posts.Post,
|
||||||
@@ -386,6 +415,8 @@ defmodule BDS.Media do
|
|||||||
)
|
)
|
||||||
end
|
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
|
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
|
case {Repo.get(Media, media_id), Repo.get(BDS.Posts.Post, post_id)} do
|
||||||
{nil, _post} ->
|
{nil, _post} ->
|
||||||
@@ -424,6 +455,8 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
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
|
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
|
case Repo.get(Media, media_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -444,6 +477,7 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec thumbnail_paths(Media.t()) :: %{required(atom()) => String.t()}
|
||||||
def thumbnail_paths(%Media{id: id}) do
|
def thumbnail_paths(%Media{id: id}) do
|
||||||
prefix = String.slice(id, 0, 2)
|
prefix = String.slice(id, 0, 2)
|
||||||
|
|
||||||
@@ -455,6 +489,7 @@ defmodule BDS.Media do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec regenerate_thumbnails(String.t()) :: {:ok, Media.t()} | {:error, :not_found | term()}
|
||||||
def regenerate_thumbnails(media_id) do
|
def regenerate_thumbnails(media_id) do
|
||||||
case Repo.get(Media, media_id) do
|
case Repo.get(Media, media_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -467,6 +502,8 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
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
|
def regenerate_missing_thumbnails(project_id, opts \\ []) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
on_progress = progress_callback(opts)
|
on_progress = progress_callback(opts)
|
||||||
@@ -518,6 +555,7 @@ defmodule BDS.Media do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec rebuild_media_from_files(String.t(), rebuild_opts()) :: {:ok, [Media.t()]}
|
||||||
def rebuild_media_from_files(project_id, opts \\ []) do
|
def rebuild_media_from_files(project_id, opts \\ []) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
on_progress = progress_callback(opts)
|
on_progress = progress_callback(opts)
|
||||||
|
|||||||
@@ -9,6 +9,29 @@ defmodule BDS.Media.Media do
|
|||||||
@primary_key {:id, :string, autogenerate: false}
|
@primary_key {:id, :string, autogenerate: false}
|
||||||
@foreign_key_type :string
|
@foreign_key_type :string
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
id: String.t() | nil,
|
||||||
|
project_id: String.t() | nil,
|
||||||
|
project: term(),
|
||||||
|
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
|
schema "media" do
|
||||||
belongs_to :project, BDS.Projects.Project, type: :string
|
belongs_to :project, BDS.Projects.Project, type: :string
|
||||||
|
|
||||||
@@ -31,6 +54,7 @@ defmodule BDS.Media.Media do
|
|||||||
field :updated_at, :integer
|
field :updated_at, :integer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(media, attrs) do
|
def changeset(media, attrs) do
|
||||||
media
|
media
|
||||||
|> cast(
|
|> cast(
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ defmodule BDS.Media.Translation do
|
|||||||
@primary_key {:id, :string, autogenerate: false}
|
@primary_key {:id, :string, autogenerate: false}
|
||||||
@foreign_key_type :string
|
@foreign_key_type :string
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
id: String.t() | nil,
|
||||||
|
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
|
schema "media_translations" do
|
||||||
belongs_to :media, BDS.Media.Media,
|
belongs_to :media, BDS.Media.Media,
|
||||||
foreign_key: :translation_for,
|
foreign_key: :translation_for,
|
||||||
@@ -22,6 +35,7 @@ defmodule BDS.Media.Translation do
|
|||||||
field :updated_at, :integer
|
field :updated_at, :integer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(translation, attrs) do
|
def changeset(translation, attrs) do
|
||||||
translation
|
translation
|
||||||
|> cast(
|
|> cast(
|
||||||
|
|||||||
@@ -36,16 +36,26 @@ defmodule BDS.Metadata do
|
|||||||
"zinc"
|
"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
|
def get_project_metadata(project_id) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
{:ok, load_state(project)}
|
{:ok, load_state(project)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec read_project_metadata_from_filesystem(String.t()) :: {:ok, metadata_state()}
|
||||||
def read_project_metadata_from_filesystem(project_id) do
|
def read_project_metadata_from_filesystem(project_id) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
{:ok, load_state_from_filesystem(project)}
|
{:ok, load_state_from_filesystem(project)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec update_project_metadata(String.t(), attrs()) ::
|
||||||
|
{:ok, metadata_state()} | {:error, term()}
|
||||||
def update_project_metadata(project_id, attrs) do
|
def update_project_metadata(project_id, attrs) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
state = load_state(project)
|
state = load_state(project)
|
||||||
@@ -85,6 +95,7 @@ defmodule BDS.Metadata do
|
|||||||
|> maybe_backfill_embeddings(project_id, state, project_metadata)
|
|> maybe_backfill_embeddings(project_id, state, project_metadata)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec add_category(String.t(), String.t()) :: {:ok, metadata_state()} | {:error, term()}
|
||||||
def add_category(project_id, name) do
|
def add_category(project_id, name) do
|
||||||
update_state(project_id, fn project, state, now ->
|
update_state(project_id, fn project, state, now ->
|
||||||
categories =
|
categories =
|
||||||
@@ -100,6 +111,7 @@ defmodule BDS.Metadata do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec remove_category(String.t(), String.t()) :: {:ok, metadata_state()} | {:error, term()}
|
||||||
def remove_category(project_id, name) do
|
def remove_category(project_id, name) do
|
||||||
update_state(project_id, fn project, state, now ->
|
update_state(project_id, fn project, state, now ->
|
||||||
categories = Enum.reject(state.categories, &(&1 == name))
|
categories = Enum.reject(state.categories, &(&1 == name))
|
||||||
@@ -113,6 +125,8 @@ defmodule BDS.Metadata do
|
|||||||
end)
|
end)
|
||||||
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
|
def update_category_settings(project_id, category, settings) do
|
||||||
update_state(project_id, fn project, state, now ->
|
update_state(project_id, fn project, state, now ->
|
||||||
normalized = normalize_category_settings(settings)
|
normalized = normalize_category_settings(settings)
|
||||||
@@ -124,6 +138,8 @@ defmodule BDS.Metadata do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec set_publishing_preferences(String.t(), map()) ::
|
||||||
|
{:ok, metadata_state()} | {:error, term()}
|
||||||
def set_publishing_preferences(project_id, prefs) do
|
def set_publishing_preferences(project_id, prefs) do
|
||||||
update_state(project_id, fn project, state, now ->
|
update_state(project_id, fn project, state, now ->
|
||||||
publishing_preferences = normalize_publishing_preferences(prefs)
|
publishing_preferences = normalize_publishing_preferences(prefs)
|
||||||
@@ -133,6 +149,8 @@ defmodule BDS.Metadata do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec sync_project_metadata_from_filesystem(String.t()) ::
|
||||||
|
{:ok, metadata_state()} | {:error, term()}
|
||||||
def sync_project_metadata_from_filesystem(project_id) do
|
def sync_project_metadata_from_filesystem(project_id) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
@@ -167,6 +185,7 @@ defmodule BDS.Metadata do
|
|||||||
|> unwrap_transaction()
|
|> unwrap_transaction()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec flush_project_metadata_to_filesystem(String.t()) :: {:ok, metadata_state()}
|
||||||
def flush_project_metadata_to_filesystem(project_id) do
|
def flush_project_metadata_to_filesystem(project_id) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
state = load_state(project)
|
state = load_state(project)
|
||||||
|
|||||||
@@ -21,6 +21,35 @@ defmodule BDS.Posts do
|
|||||||
alias BDS.Slug
|
alias BDS.Slug
|
||||||
alias BDS.Tasks
|
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
|
def create_post(attrs) do
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
project_id = attr(attrs, :project_id)
|
project_id = attr(attrs, :project_id)
|
||||||
@@ -66,6 +95,8 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec update_post(String.t(), attrs()) ::
|
||||||
|
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def update_post(post_id, attrs) do
|
def update_post(post_id, attrs) do
|
||||||
case Repo.get(Post, post_id) do
|
case Repo.get(Post, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -107,6 +138,8 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec publish_post(String.t()) ::
|
||||||
|
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def publish_post(post_id) do
|
def publish_post(post_id) do
|
||||||
case Repo.get(Post, post_id) do
|
case Repo.get(Post, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -149,6 +182,7 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec rebuild_posts_from_files(String.t(), rebuild_opts()) :: {:ok, [Post.t()]}
|
||||||
def rebuild_posts_from_files(project_id, opts \\ []) do
|
def rebuild_posts_from_files(project_id, opts \\ []) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
on_progress = progress_callback(opts)
|
on_progress = progress_callback(opts)
|
||||||
@@ -200,6 +234,8 @@ defmodule BDS.Posts do
|
|||||||
{:ok, posts}
|
{:ok, posts}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec discard_post_changes(String.t()) ::
|
||||||
|
{:ok, Post.t()} | {:error, :not_found}
|
||||||
def discard_post_changes(post_id) do
|
def discard_post_changes(post_id) do
|
||||||
case Repo.get(Post, post_id) do
|
case Repo.get(Post, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -222,6 +258,7 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
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{content: content}) when is_binary(content), do: content
|
||||||
|
|
||||||
def editor_body(%Post{project_id: project_id, file_path: file_path})
|
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: ""
|
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
|
def sync_post_from_file(post_id) do
|
||||||
case Repo.get(Post, post_id) do
|
case Repo.get(Post, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -268,6 +306,8 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
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
|
def sync_post_translation_from_file(translation_id) do
|
||||||
case Repo.get(Translation, translation_id) do
|
case Repo.get(Translation, translation_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -289,6 +329,8 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec rewrite_published_post_translation(String.t()) ::
|
||||||
|
{:ok, Translation.t()} | {:error, :not_found}
|
||||||
def rewrite_published_post_translation(translation_id) do
|
def rewrite_published_post_translation(translation_id) do
|
||||||
case Repo.get(Translation, translation_id) do
|
case Repo.get(Translation, translation_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -305,6 +347,8 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
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
|
def import_orphan_post_file(project_id, relative_path) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
full_path = Path.join(Projects.project_data_dir(project), relative_path)
|
full_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||||
@@ -327,6 +371,8 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
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
|
def import_orphan_post_translation_file(project_id, relative_path) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
full_path = Path.join(Projects.project_data_dir(project), relative_path)
|
full_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||||
@@ -359,6 +405,7 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec delete_post(String.t()) :: {:ok, :deleted} | {:error, :not_found}
|
||||||
def delete_post(post_id) do
|
def delete_post(post_id) do
|
||||||
case Repo.get(Post, post_id) do
|
case Repo.get(Post, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -376,6 +423,8 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec archive_post(String.t()) ::
|
||||||
|
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def archive_post(post_id) do
|
def archive_post(post_id) do
|
||||||
case Repo.get(Post, post_id) do
|
case Repo.get(Post, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -402,10 +451,14 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec get_post!(String.t()) :: Post.t()
|
||||||
def get_post!(post_id), do: Repo.get!(Post, post_id)
|
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)
|
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
|
def publish_post_translation(post_id, language) do
|
||||||
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
||||||
|
|
||||||
@@ -424,6 +477,7 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec slug_available(String.t(), String.t(), String.t() | nil) :: boolean()
|
||||||
def slug_available(project_id, slug, exclude_post_id \\ nil) do
|
def slug_available(project_id, slug, exclude_post_id \\ nil) do
|
||||||
normalized_slug = slug |> to_string() |> String.trim()
|
normalized_slug = slug |> to_string() |> String.trim()
|
||||||
|
|
||||||
@@ -441,6 +495,7 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
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
|
def unique_slug_for_title(project_id, title, exclude_post_id \\ nil) do
|
||||||
base_slug = title |> default_slug_source() |> Slug.slugify()
|
base_slug = title |> default_slug_source() |> Slug.slugify()
|
||||||
|
|
||||||
@@ -455,6 +510,7 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec dashboard_stats(String.t()) :: dashboard_stats()
|
||||||
def dashboard_stats(project_id) do
|
def dashboard_stats(project_id) do
|
||||||
Repo.all(
|
Repo.all(
|
||||||
from(post in Post,
|
from(post in Post,
|
||||||
@@ -477,6 +533,7 @@ defmodule BDS.Posts do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec post_counts_by_year_month(String.t()) :: [month_count()]
|
||||||
def post_counts_by_year_month(project_id) do
|
def post_counts_by_year_month(project_id) do
|
||||||
Repo.all(
|
Repo.all(
|
||||||
from(post in Post,
|
from(post in Post,
|
||||||
@@ -493,6 +550,7 @@ defmodule BDS.Posts do
|
|||||||
|> Enum.sort_by(fn %{year: year, month: month} -> {-year, -month} end)
|
|> Enum.sort_by(fn %{year: year, month: month} -> {-year, -month} end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec rebuild_post_links(String.t(), rebuild_opts()) :: :ok
|
||||||
def rebuild_post_links(project_id, opts \\ []) do
|
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))
|
post_ids = Repo.all(from(post in Post, where: post.project_id == ^project_id, select: post.id))
|
||||||
on_progress = progress_callback(opts)
|
on_progress = progress_callback(opts)
|
||||||
@@ -517,6 +575,7 @@ defmodule BDS.Posts do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec list_post_translations(String.t()) :: {:ok, [Translation.t()]}
|
||||||
def list_post_translations(post_id) do
|
def list_post_translations(post_id) do
|
||||||
{:ok,
|
{:ok,
|
||||||
Repo.all(
|
Repo.all(
|
||||||
@@ -526,6 +585,8 @@ defmodule BDS.Posts do
|
|||||||
)}
|
)}
|
||||||
end
|
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
|
def upsert_post_translation(post_id, language, attrs) do
|
||||||
case Repo.get(Post, post_id) do
|
case Repo.get(Post, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -566,6 +627,7 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec delete_post_translation(String.t()) :: {:ok, :deleted} | {:error, :not_found}
|
||||||
def delete_post_translation(translation_id) do
|
def delete_post_translation(translation_id) do
|
||||||
case Repo.get(Translation, translation_id) do
|
case Repo.get(Translation, translation_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -579,6 +641,7 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec validate_translations(String.t(), rebuild_opts()) :: {:ok, translation_validation_report()}
|
||||||
def validate_translations(project_id, opts \\ []) do
|
def validate_translations(project_id, opts \\ []) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
@@ -657,6 +720,13 @@ defmodule BDS.Posts do
|
|||||||
}}
|
}}
|
||||||
end
|
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
|
def fix_invalid_translations(report) when is_map(report) do
|
||||||
normalized_report = normalize_translation_validation_report(report)
|
normalized_report = normalize_translation_validation_report(report)
|
||||||
|
|
||||||
@@ -693,6 +763,7 @@ defmodule BDS.Posts do
|
|||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec rewrite_published_post(String.t()) :: :ok
|
||||||
def rewrite_published_post(post_id) do
|
def rewrite_published_post(post_id) do
|
||||||
post = Repo.get!(Post, post_id)
|
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}
|
%{post_id: post.id, translation_id: saved_translation.id, language: language}
|
||||||
else
|
else
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
other -> {:error, other}
|
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
auto_translation_task_attrs(post)
|
auto_translation_task_attrs(post)
|
||||||
@@ -1294,7 +1364,6 @@ defmodule BDS.Posts do
|
|||||||
%{media_id: media_id, translation_id: saved_translation.id, language: language}
|
%{media_id: media_id, translation_id: saved_translation.id, language: language}
|
||||||
else
|
else
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
other -> {:error, other}
|
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
auto_translation_task_attrs(post)
|
auto_translation_task_attrs(post)
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ defmodule BDS.Posts.Link do
|
|||||||
@primary_key {:id, :string, autogenerate: false}
|
@primary_key {:id, :string, autogenerate: false}
|
||||||
@foreign_key_type :string
|
@foreign_key_type :string
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
id: String.t() | nil,
|
||||||
|
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
|
schema "post_links" do
|
||||||
belongs_to :source_post, BDS.Posts.Post, foreign_key: :source_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
|
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
|
field :created_at, :integer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(link, attrs) do
|
def changeset(link, attrs) do
|
||||||
link
|
link
|
||||||
|> cast(attrs, [:id, :source_post_id, :target_post_id, :link_text, :created_at])
|
|> cast(attrs, [:id, :source_post_id, :target_post_id, :link_text, :created_at])
|
||||||
|
|||||||
@@ -10,6 +10,35 @@ defmodule BDS.Posts.Post do
|
|||||||
@foreign_key_type :string
|
@foreign_key_type :string
|
||||||
@statuses [:draft, :published, :archived]
|
@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
|
schema "posts" do
|
||||||
belongs_to :project, BDS.Projects.Project, type: :string
|
belongs_to :project, BDS.Projects.Project, type: :string
|
||||||
|
|
||||||
@@ -36,6 +65,7 @@ defmodule BDS.Posts.Post do
|
|||||||
field :published_excerpt, :string
|
field :published_excerpt, :string
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(post, attrs) do
|
def changeset(post, attrs) do
|
||||||
post
|
post
|
||||||
|> cast(
|
|> cast(
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ defmodule BDS.Posts.Translation do
|
|||||||
@foreign_key_type :string
|
@foreign_key_type :string
|
||||||
@statuses [:draft, :published]
|
@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
|
schema "post_translations" do
|
||||||
belongs_to :post, BDS.Posts.Post,
|
belongs_to :post, BDS.Posts.Post,
|
||||||
foreign_key: :translation_for,
|
foreign_key: :translation_for,
|
||||||
@@ -27,6 +46,7 @@ defmodule BDS.Posts.Translation do
|
|||||||
field :checksum, :string
|
field :checksum, :string
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(translation, attrs) do
|
def changeset(translation, attrs) do
|
||||||
translation
|
translation
|
||||||
|> cast(
|
|> cast(
|
||||||
|
|||||||
@@ -13,14 +13,35 @@ defmodule BDS.Projects do
|
|||||||
@default_project_id "default"
|
@default_project_id "default"
|
||||||
@default_project_name "My Blog"
|
@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
|
def list_projects do
|
||||||
Repo.all(from project in Project, order_by: [asc: project.created_at])
|
Repo.all(from project in Project, order_by: [asc: project.created_at])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec get_active_project() :: Project.t() | nil
|
||||||
def get_active_project do
|
def get_active_project do
|
||||||
Repo.one(from project in Project, where: project.is_active == true, limit: 1)
|
Repo.one(from project in Project, where: project.is_active == true, limit: 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec shell_snapshot() :: shell_snapshot()
|
||||||
def shell_snapshot do
|
def shell_snapshot do
|
||||||
_ = ensure_default_project()
|
_ = ensure_default_project()
|
||||||
projects = list_projects()
|
projects = list_projects()
|
||||||
@@ -32,9 +53,13 @@ defmodule BDS.Projects do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec get_project(String.t()) :: Project.t() | nil
|
||||||
def get_project(id), do: Repo.get(Project, id)
|
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)
|
def get_project!(id), do: Repo.get!(Project, id)
|
||||||
|
|
||||||
|
@spec ensure_default_project() :: {:ok, Project.t()} | {:error, term()}
|
||||||
def ensure_default_project do
|
def ensure_default_project do
|
||||||
case Repo.get(Project, @default_project_id) do
|
case Repo.get(Project, @default_project_id) do
|
||||||
%Project{} = project ->
|
%Project{} = project ->
|
||||||
@@ -69,16 +94,19 @@ defmodule BDS.Projects do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec project_data_dir(Project.t()) :: String.t()
|
||||||
def project_data_dir(%Project{} = project) do
|
def project_data_dir(%Project{} = project) do
|
||||||
project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__)
|
project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__)
|
||||||
end
|
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{} = project), do: project_cache_dir(project.id)
|
||||||
|
|
||||||
def project_cache_dir(project_id) when is_binary(project_id) do
|
def project_cache_dir(project_id) when is_binary(project_id) do
|
||||||
Path.join([project_cache_root(), "projects", project_id])
|
Path.join([project_cache_root(), "projects", project_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec create_project(attrs()) :: {:ok, Project.t()} | {:error, term()}
|
||||||
def create_project(attrs) do
|
def create_project(attrs) do
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
name = attr(attrs, :name) || ""
|
name = attr(attrs, :name) || ""
|
||||||
@@ -108,6 +136,7 @@ defmodule BDS.Projects do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec set_active_project(String.t()) :: {:ok, Project.t()} | {:error, :not_found | term()}
|
||||||
def set_active_project(project_id) do
|
def set_active_project(project_id) do
|
||||||
case Repo.get(Project, project_id) do
|
case Repo.get(Project, project_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -133,6 +162,9 @@ defmodule BDS.Projects do
|
|||||||
end
|
end
|
||||||
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
|
def delete_project(project_id) when is_binary(project_id) do
|
||||||
case Repo.get(Project, project_id) do
|
case Repo.get(Project, project_id) do
|
||||||
nil ->
|
nil ->
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ defmodule BDS.Projects.Project do
|
|||||||
@primary_key {:id, :string, autogenerate: false}
|
@primary_key {:id, :string, autogenerate: false}
|
||||||
@foreign_key_type :string
|
@foreign_key_type :string
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
id: String.t() | nil,
|
||||||
|
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
|
schema "projects" do
|
||||||
field :name, :string
|
field :name, :string
|
||||||
field :slug, :string
|
field :slug, :string
|
||||||
@@ -19,6 +31,7 @@ defmodule BDS.Projects.Project do
|
|||||||
has_many :posts, BDS.Posts.Post, foreign_key: :project_id
|
has_many :posts, BDS.Posts.Post, foreign_key: :project_id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(project, attrs) do
|
def changeset(project, attrs) do
|
||||||
project
|
project
|
||||||
|> cast(
|
|> cast(
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ defmodule BDS.Publishing do
|
|||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Tasks
|
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
|
def start_link(_opts) do
|
||||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec upload_site(String.t(), credentials(), keyword()) :: {:ok, String.t()} | {:error, term()}
|
||||||
def upload_site(project_id, credentials, opts \\ [])
|
def upload_site(project_id, credentials, opts \\ [])
|
||||||
when is_binary(project_id) and is_map(credentials) and is_list(opts) do
|
when is_binary(project_id) and is_map(credentials) and is_list(opts) do
|
||||||
project = Projects.get_project!(project_id)
|
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})
|
GenServer.call(__MODULE__, {:upload_site, project_id, normalized_credentials, targets, opts})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec get_job(String.t()) :: PublishJob.t() | nil
|
||||||
def get_job(job_id) when is_binary(job_id) do
|
def get_job(job_id) when is_binary(job_id) do
|
||||||
GenServer.call(__MODULE__, {:get_job, job_id})
|
GenServer.call(__MODULE__, {:get_job, job_id})
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,24 @@ defmodule BDS.Publishing.PublishJob do
|
|||||||
@primary_key {:id, :string, autogenerate: false}
|
@primary_key {:id, :string, autogenerate: false}
|
||||||
@foreign_key_type :string
|
@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
|
schema "publish_jobs" do
|
||||||
field :project_id, :string
|
field :project_id, :string
|
||||||
field :ssh_host, :string
|
field :ssh_host, :string
|
||||||
@@ -21,6 +39,7 @@ defmodule BDS.Publishing.PublishJob do
|
|||||||
field :updated_at, :integer
|
field :updated_at, :integer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(job, attrs) do
|
def changeset(job, attrs) do
|
||||||
job
|
job
|
||||||
|> cast(
|
|> cast(
|
||||||
|
|||||||
@@ -62,10 +62,18 @@ defmodule BDS.Search do
|
|||||||
{"it", ~w(il lo la gli le un una uno e che per con ogni mattina)}
|
{"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
|
def list_stemmer_languages do
|
||||||
@stemmer_languages
|
@stemmer_languages
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec detect_language(String.t() | nil) :: String.t()
|
||||||
def detect_language(text) do
|
def detect_language(text) do
|
||||||
normalized_text = text |> to_string() |> String.downcase()
|
normalized_text = text |> to_string() |> String.downcase()
|
||||||
|
|
||||||
@@ -78,6 +86,7 @@ defmodule BDS.Search do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec stem(String.t() | nil, String.t() | nil) :: String.t()
|
||||||
def stem(text, language \\ nil) do
|
def stem(text, language \\ nil) do
|
||||||
language = normalize_language(language || detect_language(text))
|
language = normalize_language(language || detect_language(text))
|
||||||
|
|
||||||
@@ -86,6 +95,14 @@ defmodule BDS.Search do
|
|||||||
|> Enum.map_join(" ", &stem_token(&1, language))
|
|> Enum.map_join(" ", &stem_token(&1, language))
|
||||||
end
|
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
|
def search_posts(project_id, query, filters \\ %{}) do
|
||||||
filters = normalize_filters(filters)
|
filters = normalize_filters(filters)
|
||||||
|
|
||||||
@@ -104,6 +121,14 @@ defmodule BDS.Search do
|
|||||||
}}
|
}}
|
||||||
end
|
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
|
def search_media(project_id, query, filters \\ %{}) do
|
||||||
filters = normalize_filters(filters)
|
filters = normalize_filters(filters)
|
||||||
|
|
||||||
@@ -121,6 +146,7 @@ defmodule BDS.Search do
|
|||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec reindex_project(String.t()) :: :ok
|
||||||
def reindex_project(project_id) do
|
def reindex_project(project_id) do
|
||||||
:ok = reindex_posts(project_id)
|
:ok = reindex_posts(project_id)
|
||||||
:ok = reindex_media(project_id)
|
:ok = reindex_media(project_id)
|
||||||
@@ -128,6 +154,7 @@ defmodule BDS.Search do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec reindex_posts(String.t(), reindex_opts()) :: :ok
|
||||||
def reindex_posts(project_id, opts \\ []) do
|
def reindex_posts(project_id, opts \\ []) do
|
||||||
Repo.query!(
|
Repo.query!(
|
||||||
"DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)",
|
"DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)",
|
||||||
@@ -150,6 +177,7 @@ defmodule BDS.Search do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec reindex_media(String.t(), reindex_opts()) :: :ok
|
||||||
def reindex_media(project_id, opts \\ []) do
|
def reindex_media(project_id, opts \\ []) do
|
||||||
Repo.query!(
|
Repo.query!(
|
||||||
"DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)",
|
"DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)",
|
||||||
@@ -172,6 +200,7 @@ defmodule BDS.Search do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec sync_post(Post.t() | String.t()) :: :ok
|
||||||
def sync_post(%Post{} = post) do
|
def sync_post(%Post{} = post) do
|
||||||
delete_post(post.id)
|
delete_post(post.id)
|
||||||
insert_post_index(post)
|
insert_post_index(post)
|
||||||
@@ -185,6 +214,7 @@ defmodule BDS.Search do
|
|||||||
end
|
end
|
||||||
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: post_id}), do: delete_post(post_id)
|
||||||
|
|
||||||
def delete_post(post_id) when is_binary(post_id) do
|
def delete_post(post_id) when is_binary(post_id) do
|
||||||
@@ -192,6 +222,7 @@ defmodule BDS.Search do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec sync_media(Media.t() | String.t()) :: :ok
|
||||||
def sync_media(%Media{} = media) do
|
def sync_media(%Media{} = media) do
|
||||||
delete_media(media.id)
|
delete_media(media.id)
|
||||||
insert_media_index(media)
|
insert_media_index(media)
|
||||||
@@ -205,6 +236,7 @@ defmodule BDS.Search do
|
|||||||
end
|
end
|
||||||
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: media_id}), do: delete_media(media_id)
|
||||||
|
|
||||||
def delete_media(media_id) when is_binary(media_id) do
|
def delete_media(media_id) when is_binary(media_id) do
|
||||||
|
|||||||
Reference in New Issue
Block a user