fix: reworking some code smell issues

This commit is contained in:
2026-06-26 16:34:07 +02:00
parent 68de265137
commit d1acfd4d71
42 changed files with 661 additions and 129 deletions

319
AUDIT.md Normal file
View File

@@ -0,0 +1,319 @@
# Elixir Antipatterns Audit (v2)
Incorporates reviewer corrections (v1 listed some already-moduledoced/specced modules as undocumented; mischaracterised `git.ex:724`; implied `shell_live.ex` is still monolithic when its event handlers are already delegated to `OverlayManager`, `SidebarEvents`, `TitlebarMenu` etc.; overstated the risk of `search.ex:744` whose atom is already bounded by `@stemmer_algorithms`; missed reusable anchors like `MapUtils`, `Slug`, `I18n`). `mix credo --strict` passes clean — everything below is a semantic antipattern lint cannot catch.
Static review of `lib/` (246 files, ~58k LOC).
---
## H1 — Stray `Logger.info` leaking raw user input
`lib/bds/desktop/shell_live/chat_editor.ex:93` logs `socket.assigns.input` at `info` level; `:98` is a sibling stray. These two lines violate AGENTS.md and leak raw user input to production logs. Everywhere else the codebase uses `Logger.debug` with a `swallowed` convention.
### Attack
1. Delete `chat_editor.ex:93` and `:98`.
2. `grep -rn "Logger\.info(" lib` and audit every other hit (only legitimate operational events should remain).
3. Optional: add a credo check banning `Logger.info(inspect(` in `LiveView` modules.
### Test strategy
No test changes — pure deletion.
---
## H2 — Hardcoded user-facing language-name map
`lib/bds/desktop/shell_live/overlay_components.ex:209-217`:
```elixir
defp language_names do
%{"en" => "English", "de" => "Deutsch", "fr" => "Francais", "it" => "Italiano", "es" => "Espanol"}
end
```
Violates AGENTS.md ("No hardcoded user-facing text. No exceptions.") The repo already has the right central home: `BDS.I18n` (`lib/bds/i18n.ex:1`) already holds `@supported_languages`, flags, format locales, and language metadata.
### Chosen semantic: native autonyms
Language labels in the overlay are language *self-names* ("Deutsch", "Español") — they identify a language, they don't need to be translated into the UI locale. A German user seeing "Español" for Spanish is correct (it's the Spanish self-name), just as `@supported_languages` already lists flags per language without translating the flag.
Therefore: **centralise in `BDS.I18n`, no Gettext needed.** Do not introduce `dgettext("ui", "language.<code>")` for this — that would create a translation task with no real user benefit.
### Attack
1. Add `BDS.I18n.language_name/1` returning the native autonym for each supported code: `en → "English"`, `de → "Deutsch"`, `fr → "Français"`, `it → "Italiano"`, `es → "Español"`. Keep aligned with `@supported_languages`.
2. Replace `language_names/0` in `overlay_components.ex:209` with a `BDS.I18n` lookup (build the map from `I18n.supported_languages/0` and `I18n.language_name/1`).
3. Do NOT run `mix gettext.extract` for this — no new `msgid` entries.
### Test strategy
Add a test asserting `I18n.language_name/1` covers exactly `@supported_language_codes` and each value is non-empty.
---
## H3 — Shared-helper duplication with semantic drift
Already-existing canonical anchors (reuse these, don't invent new modules first):
- `BDS.MapUtils.blank_to_nil/1` (`lib/bds/map_utils.ex:33-35`) — used in `media/file_ops.ex:10` via `defdelegate`. Currently only handles `nil` and `""`. **Note on naming**: `MapUtils` is the existing home for `blank_to_nil/1`, but new scalar predicates (`present?/1`, `blank?/1`, `truthy?/1`) do **not** belong in a module called "MapUtils" — they operate on scalar values, not maps. Add a new module `BDS.Values` (or `BDS.Booleans`) for those, and have `BDS.Values.blank_to_nil/1` `defdelegate` to `BDS.MapUtils.blank_to_nil/1` to keep the existing 20 callers untouched. The existing `blank_to_nil/1` stays in `MapUtils` to avoid churn.
- `BDS.Slug.slugify/1` (`lib/bds/slug.ex:6`) — used in `posts.ex`, `templates.ex`, `posts/slugs.ex`. Canonical slugifier.
- `BDS.I18n` (`lib/bds/i18n.ex`) — already centralizes supported-language metadata (see H2).
### Inventory
| Helper | defs | Sites | Disagreement | Canonical source |
|---|---|---|---|---|
| `truthy?/1` | 13 | `scripting/capabilities/util.ex:160`, `ai/catalog.ex:329`, `chat_editor.ex:584`, `chat_editor/tool_surfaces.ex:306`, `post_editor/post_metadata.ex:213`, `post_editor/draft_management.ex:230`, `settings_editor/{project,editor,managed_categories,ai}_settings.ex` | `1.0`? `"on"`? order of clauses? **Adding `"on"` to `ai/catalog.ex` is a silent widening — see attack step 1.** | **ADD** `BDS.Values.truthy?/1` (new module — scalar predicates don't belong in `MapUtils`) |
| `present?/1` | 8 | `ui/sidebar.ex:1128`, `posts/translation_validation.ex:495`, `posts/auto_translation.ex:447`, `import_editor/taxonomy_editing.ex:287`, `import_editor/analysis_state.ex:313`, `import_editor.ex:1455`, `chat_editor.ex:1034`, `panel_renderer.ex:303` | **whitespace trim?**`translation_validation`, `auto_translation`, `chat_editor` trim; the other 5 do NOT | **ADD** `BDS.Values.present?/1` (non-trim canonical, matches current `blank_to_nil/1`) |
| `blank?/1` | 8 | `ui/dashboard.ex:197`, `post_editor/post_metadata.ex:218`, `import_editor.ex:1456`, `chat_editor/model_selection.ex:79`, `ai/runtime.ex:110`, `ai/chat_tools.ex:708`, `ai/chat.ex:938` | ditto whitespace | **ADD** `BDS.Values.blank?/1` (negation of `present?/1`) |
| `blank_to_nil/1` | 14 local + 20 direct `MapUtils.blank_to_nil/1` callers | see grep above | most agree: `nil`/`""``nil`; `git.ex:724` adds `"\n"`; `maintenance/diff_computation.ex:33-42` and `scripting/capabilities/util.ex:166-172` trim; the rest don't. **`MapUtils` version is non-trimming and load-bearing — do not mutate.** | **REUSE** `BDS.MapUtils.blank_to_nil/1` as-is; add `trim_or_nil/1` separately if needed |
| `pad2/1` | 4 | `scripting/capabilities/util.ex:163`, `post_editor/post_metadata.ex:207`, `overlay_components.ex:408`, `rendering/links_and_languages.ex:141` | identical bodies | **NEW** `BDS.Strings.pad2/1` |
| `slugify/1` | 2 custom | `rendering/filters.ex:74` (Liquex filter, leave), `overlay_components.ex:410` (delete; reuse `BDS.Slug.slugify/1`) | — | **REUSE** `BDS.Slug.slugify/1` |
### Required deliverable before any deletion
A table per helper of the form "site → semantic that site actually relies on → chosen canonical". Without it, deduping `present?/1` silently changes whitespace behaviour at the trim-using sites (and vice versa).
### ⚠ WARNING — `MapUtils.blank_to_nil/1` semantic change is a repo-wide migration, not a dedup
`BDS.MapUtils.blank_to_nil/1` (`map_utils.ex:33-35`) currently does **no trimming** — it maps `nil → nil`, `"" → nil`, anything-else → as-is. It has **20 direct callers** across `tags.ex`, `import_editor/*`, `ai/chat_tools.ex`, `ai/chat_tool_query_helpers.ex`, `wxr_parser.ex`, `import_analysis.ex`, and a `defdelegate` in `media/file_ops.ex:10`. Several of those callers already chain `|> String.trim() |> BDS.MapUtils.blank_to_nil()` (e.g. `taxonomy_editing.ex:153,232`), which proves the current non-trimming contract is load-bearing — callers compose trimming themselves.
Changing `map_utils.ex:33` to trim globally would **double-trim** at those sites and silently alter whitespace behaviour at every other caller. This is a migration, not a canonicalization.
### Attack
1. **Do not touch `BDS.MapUtils.blank_to_nil/1` semantics.** Leave its current contract (non-trimming) intact.
2. Create a new module `BDS.Values` for scalar predicates (do **not** add them to `MapUtils` — they're not map utilities). Have `BDS.Values.blank_to_nil/1` `defdelegate` to `BDS.MapUtils.blank_to_nil/1` to keep existing 20 callers untouched:
- `BDS.Values.present?/1``blank_to_nil(v) != nil` (non-trim semantic — matches the current `blank_to_nil/1` contract and the 5 non-trim `present?` sites).
- `BDS.Values.blank?/1``blank_to_nil(v) == nil` (negation).
- `BDS.Values.truthy?/1` → canonical accept set `true, "true", "on", 1, 1.0, "1"`.
- Optional `BDS.Values.trim_or_nil/1` for the 3 sites that want trim-with-nil-empty — let those call sites compose `String.trim()` + `blank_to_nil/1` explicitly (as `taxonomy_editing.ex` already does), do not force a new helper on them.
3. **`truthy?/1` accept-set widening is a real semantic change at `ai/catalog.ex:329`.** Current local: `value in [true, "true", 1, "1"]`. Canonical adds `"on"` and `1.0`. The `catalog.ex:114-116` call sites feed `truthy?(BDS.MapUtils.attr(attrs, :supports_attachment))` etc. where `attrs` comes from external catalog config.
- Verify that catalog config never produces `"on"` or `1.0` for those fields. If it can, EITHER keep `catalog.ex:329`'s local `defp` with its narrower accept set, OR prove via test that widening is safe (e.g. upstream already normalizes to `true`/`"true"`/`1`).
- Do NOT blanket-alias `BDS.Values.truthy?/1` at `catalog.ex` without that verification. The "check callers" caveat is load-bearing.
3. **Migrate sites in two groups — don't blanket-delete.** The local `defp`'s split by semantic:
- **Non-trim sites** (matches canonical `BDS.Values.present?/1`): `ui/sidebar.ex:1128`, `import_editor/taxonomy_editing.ex:287`, `import_editor/analysis_state.ex:313`, `import_editor.ex:1455`, `panel_renderer.ex:303` — delete local `defp`, `alias BDS.Values`. Same for `blank?/1` non-trim sites: `ui/dashboard.ex:197`, `import_editor.ex:1456`, `ai/runtime.ex:110`, `ai/chat.ex:938`.
- **Trim sites — DO NOT migrate to `BDS.Values.present?/1` without a deliberate behavior change.** These rely on `String.trim` semantics:
- `posts/auto_translation.ex:447` (`present?/1` trims)
- `posts/translation_validation.ex:495` (`present?/1` trims)
- `chat_editor.ex:1034` (`present?/1` trims)
- `chat_editor/model_selection.ex:79` (`blank?/1` trims)
- `ai/chat_tools.ex:708` (`blank?/1` trims with `to_string`)
- `post_editor/post_metadata.ex:218` (`blank?/1` defers to local `blank_to_nil/1` that may trim — verify)
For these sites either:
(a) leave the local `defp` in place (safe default — no behavior change), or
(b) migrate callers to `String.trim(value) |> BDS.Values.blank_to_nil(value) != nil` inline, with a test proving the trim behavior is preserved.
Do NOT alias `BDS.Values.present?/1` at these sites — `present?(" ")` returns **true** under the canonical, **false** under the current local. That is a silent semantics change.
- **`truthy?/1` sites** — apply step 2's catalog.ex check to all 13 sites before blanket-aliasing, since each may have its own narrower accept set (e.g. `ai/catalog.ex:329` omits `"on"`).
4. **Honest framing**: this is a *partial* dedup. After the migration, the canonical `BDS.Values.present?/1`/`blank?/1`/`truthy?/1` exists, but **several local `defp`s survive** at the trim sites and at sites whose accept-set differs from canonical. The goal is consolidation where safe, not full convergence — full convergence would require touching every behavior boundary. Don't oversell the payoff — this is medium-effort/high-risk dedup of one-line predicates.
5. Add `BDS.Strings.pad2/1` (or `BDS.Formatting.pad2/1`) — single 4-line implementation. Delete 4 local copies.
6. `overlay_components.ex:410` local `slugify/1` → replace with `BDS.Slug.slugify/1`.
7. Leave `git.ex:724`, `maintenance/diff_computation.ex:33`, `scripting/capabilities/util.ex:166` as-is for now — they're internal `defp`s whose callers expect their specific trim/newline behavior. File a follow-up to migrate them to `BDS.Values.trim_or_nil/1` if convergence is desired later.
### Test strategy
- New unit tests for canonical helpers covering: `nil`, `""`, `" "`, `"\n"`, `"x"`, `0`, `1`, `1.0`, `true`, `false`, `"true"`, `"on"`, `"1"`.
- Crucial: `present?(" ")` and `present?("\n")` return **true** with the non-trim canonical (matching the current `MapUtils.blank_to_nil/1` contract). A change here means every call site that expected `" "` to be blank needs migration — that is the migration mentioned above.
- After deleting a local `defp`, grep same-name to confirm no latent callers (some `defp` shadow `def` from a sibling module — careful with `scripting/capabilities/util.ex:160` which is `def`, not `defp`).
- `mix test` green. Test the post-field trim sites specifically (`posts/auto_translation.ex:447`, `posts/translation_validation.ex:495`) — they currently trim, and switching them to a non-trimming `present?/1` changes their semantics on whitespace strings.
---
## H4 — `try/rescue` for control flow and error swallowing
Most `rescue` clauses are legitimate. Categorised:
### Problem (rewrite to `case`/`with`)
`lib/bds/desktop/shell_live/overlay_components.ex`:
- `:71-77`, `:201-205`, `:257-261`, `:296-300`, `:328-338`, `:351-361`, `:390-400``{:ok, _} = Metadata.get_project_metadata(...)` followed by broad `rescue error ->`. The rescue catches a `MatchError` from a `{:error, _}` return — should be `case`/`with` over the `{:ok, _} | {:error, _}` shape.
### Over-defensive DB rescues (remove or narrow to `:error` log)
`lib/bds/desktop/shell_live/overlay_components.ex`:
- `:142`, `:157`, `:170`, `:190`, `:257` (the `Repo.all` ones) — wrap plain queries in rescue → return `[]`/`%{}`. A dropped table or lock is silently swallowed. Either drop the rescue and let it propagate, or log at `:error` (not `:warning`) and return `{:error, _}` up the call chain.
### Caller-expected fallback contracts (per function)
An agent removing a rescue needs to know what the caller expects on failure. These are the **current observed** fallbacks — preserve them unless the caller is also rewritten:
| Function | Line | Current fallback | Replace rescue with |
|---|---|---|---|
| `project_metadata/1` | `:71` | `%{main_language: "en", blog_languages: []}` | `case`/`with` returning the same map on `{:error, _}` |
| `source_language/2` (post) | `:201` | `metadata.main_language \|\| "en"` | `case` returning same on error |
| `source_language/2` (media) | `:201` | `metadata.main_language \|\| "en"` | same |
| `ai_fields/2` (post) | `:257` | `[]` | `case` returning `[]` on error |
| `ai_fields/2` (media) | `:296` | `[]` | `case` returning `[]` on error |
| `delete_details/2` (media) | `:328` | `%{title: dgettext("ui","Delete Media"), …, reference_list: []}` | `case` returning same on error |
| `delete_details/2` (tags) | `:351` | `%{title: dgettext("ui","Delete Tag"), …}` | `case` returning same on error |
| `merge_details/2` | `:390` | `%{target: "tag", count: 1, …}` | `case` returning same on error |
The 5 `Repo.all` rescues (`:142`, `:157`, `:170`, `:190`, `:257`) currently fall back to `[]` or `%{}`. If the live template already treats empty list as "no data" gracefully, the fallback is fine — but the *failure* should not be silent. Recommended: log at `:error` before returning the fallback so a DB/schema issue shows up in logs.
### Allow-list (leave alone)
- `preview.ex:246``rescue Ecto.NoResultsError -> {:error, :not_found}`. **Genuinely narrow**: single named exception type, expected control-flow case (post not found). Legitimate.
- `shutdown.ex` (10×), `main_window.ex`, `automation.ex`, `deep_link.ex`, `shell_commands.ex:630,814` — fire-and-forget teardown; `Logger.debug("swallowed ...")` then `:ok`. These explicitly tolerate `:exit`/`:noproc` during process shutdown and re-running them is racy. Legitimate.
### NOT on allow-list (re-examine)
- `embeddings/index.ex:348``rescue exception -> Logger.debug("swallowed embeddings index persist error..."); :ok`. **Broad rescue** wrapping `HNSWLib.Index.save_index/2` + `File.mkdir_p!`. A persist failure is silently swallowed and `:ok` returned — caller believes the index was saved. Operationally tolerable only because the next reindex will retry; should at minimum log at `:error` and ideally surface `{:error, _}`. **Re-examine**: not a blanket "leave alone".
- `embeddings/index.ex:393` — similar broad rescue around index load. Same caveat.
- `embeddings/backends/neural.ex:110``rescue exception -> {:reply, {:error, Exception.message(exception)}, state}`. **Broad rescue** around `Nx.Serving.run`. Unlike the silent-default cases above, this one *does* surface `{:error, _}` to the caller (good) — it's not a silent swallow. The reason it's still on the rework list is that the broad `rescue exception ->` catches programmer bugs (`FunctionClauseError`, `MatchError`) and repackages them as `{:error, "…"}` alongside genuine Nx runtime errors, hiding the distinction. **Re-examine**: restrict to expected Nx/serving exception types so programmer bugs surface unmodified.
To find: `grep -rn "rescue" lib`.
### Attack
1. For each of the 7 `overlay_components.ex` `{:ok, _} = ` rescues, rewrite as:
```elixir
case Metadata.get_project_metadata(project_id) do
{:ok, metadata} -> metadata
{:error, _reason} -> %{main_language: "en", blog_languages: []}
end
```
Drop the rescue.
2. For the 5 `Repo.all` rescues (`overlay_components.ex:142,157,170,190,257`): **preserve the documented `[]`/`%{}` fallbacks** — the caller templates consume them directly. Letting `Repo.all` propagate is a behavior change and NOT the default repair. The correct minimal repair is:
- narrow the rescue to a single named exception type if possible (`Ecto.QueryError`, `DBConnection.ConnectionError`), AND
- upgrade the log from `Logger.warning` to `Logger.error` so silent DB/schema failures are visible, AND
- keep the existing fallback return (`[]` or `%{}`) per the table above.
Only if a caller is also being rewritten to accept `{:error, _}` (and has a test for it) should the rescue be dropped in favor of propagation. Introducing a repo-wide `BDS.RepoHelpers.safe_all/2` is **one option, not the prescribed repair** — the audit does not establish enough callers want this wrapping to justify a new abstraction; keep any wrapping local unless a second use site appears.
3. Keep `log_overlay_warning/2` (or move into a shared `BDS.Logging` module — see H3 territory).
### Test strategy
- Each rewritten function needs a test for the `{:error, _}` branch — currently impossible because of `rescue`. Use mox/mocks against `Metadata` and `Repo`.
- For over-defensive DB rescues, test the legitimate DB error path returns `{:error, _}` to the caller (or surfaces it).
### Rescue allow-list reference (tightened)
- Single **named exception type** (`Ecto.NoResultsError`, `File.Error`, `ArgumentError`) — OK.
- Fire-and-forget teardown in shutdown/process-kill paths with `Logger.debug("swallowed ...")` + `:ok` — OK.
- Broad `rescue exception ->` (or `rescue error ->`) returning a **silent default** (e.g. `:ok`, `[]`, `%{}`) — **NOT OK**, even if the operation is "background". At minimum log at `:error` so programmer bugs (`MatchError`, `FunctionClauseError`) are visible. `index.ex:348,393` falls in this category.
- Broad `rescue exception ->` that **does surface `{:error, _}`** (e.g. `neural.ex:110`) — **borderline**. The failure isn't silent, but a programmer bug is indistinguishable from a genuine runtime error after repackaging. Restrict to expected exception types rather than blanket-leaving.
---
## M1 — God modules: complete the decomposition that's already underway
Decomposition has already started. Acknowledge what exists, finish the rest.
Already extracted (don't redo):
- `lib/bds/desktop/shell_live/overlay_manager.ex` — `shell_live.ex:361-410` already delegates all 15 `overlay_*` events here.
- `lib/bds/desktop/shell_live/sidebar_events.ex` — sidebar filter events.
- `lib/bds/desktop/shell_live/titlebar_menu.ex` — titlebar menu state/logic.
- `lib/bds/desktop/shell_live/bridges.ex`, `git_handler.ex`, `chat_editor.ex`, `import_editor.ex`, `media_editor.ex`, `post_editor.ex`, `misc_editor.ex`, `settings_editor.ex`, `script_editor.ex`, `template_editor.ex`, `tags_editor.ex`, `menu_editor.ex` — body already lives in these.
Still routed through `shell_live.ex:183-547` (~55 `handle_event` clauses). The remaining forwarders cluster cleanly:
- **Project cluster** (`shell_live.ex:449-481`): `toggle_project_menu`, `close_project_menu`, `select_project`, `create_project`, `import_project` — extract to `BDS.Desktop.ShellLive.ProjectEvents`.
- **Language cluster** (`shell_live.ex:514-518`): `change_ui_language`, `sync_ui_language` — extract to `BDS.Desktop.ShellLive.LocaleEvents` (and tie to `UILocale` — see M2).
- **Native menu cluster** (`shell_live.ex:527-547`): `native_menu_action`, `titlebar_menu_keydown`, `toggle_titlebar_menu`, `hover_titlebar_menu`, `close_titlebar_menu`, `titlebar_menu_action` — extract to `BDS.Desktop.ShellLive.NativeMenuEvents` (already co-located with `titlebar_menu.ex`).
- **Tab/workspace cluster** (`:196-302`): view/panel/tab/sidebar-item — extract to `BDS.Desktop.ShellLive.WorkspaceEvents`.
What stays in `shell_live.ex`: `mount`, `handle_info`, `terminate`, and a small handful of top-level routing events.
Out-of-scope decompositions (large but not urgent — touch when working nearby):
- `lib/bds/scripting/api_docs.ex` (1554 LOC) — split `Renderer` / `Generator` / `DataStructures` when scripting docs change next.
- `lib/bds/ui/sidebar.ex` (1129) — split `Sidebar.Filters` from `Sidebar.View` opportunistically.
- `lib/bds/ai/chat.ex` (939) — orchestration; leave unless actively edited.
### Attack
1. Pick one cluster. Create the module, move the matching `handle_event` clauses verbatim, leave a single delegating `handle_event` in `shell_live.ex` per event (same pattern as `:361-410`).
2. Verify with the existing LiveView test suite after each cluster.
### Test strategy
Existing LiveView tests (`live/2`) exercise events by name — they should pass unchanged after delegation.
---
## M2 — Process dictionary as contained global
`lib/bds/desktop/ui_locale.ex` is the documented sole owner of `Process.put(:bds_ui_locale, _)`. The module explicitly comments that direct use elsewhere is forbidden. This is **not a present defect** — it's a caution for future work.
### Attack
1. No retrofit needed.
2. For NEW callers needing the locale, prefer passing it explicitly via assigns or a `%Locale{}` struct rather than expanding `Process.put` callers.
3. Consider a dialyzer / credo custom check rejecting new `Process.put(:bds_ui_locale, _)` outside `UiLocale`.
Priority: **Informational**. No action required unless new callers appear.
---
## M3 — `Enum.at` brittle index use
### Real problems (rewrite)
- `lib/bds/import_analysis.ex:428-429`:
```elixir
key = Enum.at(captures, 1)
value = Enum.at(captures, 2) || Enum.at(captures, 3) || Enum.at(captures, 4) || ""
```
Rewrite with `Regex.named_captures/2` or pattern matching on the captured groups.
- `lib/bds/rendering/filters.ex:241-253` — `match |> Enum.at(1)` repeated 4× with identical shape. Hoist into a helper and pattern match.
### Style only (drop from report)
- `titlebar_menu.ex` `== nil` vs `is_nil/1` inconsistency — cosmetic.
To find: `grep -rn "Enum\.at\|Enum\.find_index\|Enum\.find_value" lib`.
### Attack
1. `import_analysis.ex:428-429` → switch regex to named groups, return map, pattern-match defaults.
2. `rendering/filters.ex:241-253` → introduce `slug_from_match/1` private helper, reuse across 4 call sites.
### Test strategy
Tests for: empty captures list, regex without the expected group, full match. Existing tests should still pass.
---
## M4 — Documentation debt
Many modules still carry `@moduledoc false`, including public-API modules. The reviewer correctly points out:
- `templates.ex:1` already has a real `@moduledoc """..."""`.
- `tasks.ex:13` is heavily `@type`/`@spec`-ed.
- `tags.ex:18` and `posts.ex:64` have broad public `@spec` coverage.
So the v1 claim "no specs" was wrong for those four. The genuine gap:
- **Missing moduledoc on API modules**: `posts.ex:1`, `media.ex:1`, `preview.ex:1`, `ai/chat.ex:1`, `embeddings.ex:1`, `generation.ex:1`, `git.ex:1`, `tags.ex:1`. Verify each individually before flipping.
- **Missing `@spec` on a few remaining public `def`s** in `preview.ex:1` — spot-apply, don't bulk-generate. (Note: `metadata.ex:1` is **fully specced** — 9/9 public defs have `@spec`. Removed from the gap list.)
- **CI gap**: `mix.exs` does not currently include `ex_doc`. "Run `mix docs`" is therefore **not** a usable verification path until `ex_doc` is wired.
### Attack
1. **Prerequisite**: add `:ex_doc` to `mix.exs` `deps` (dev-only). Configure `mix docs --warnings-as-errors` in CI.
2. Audit `grep -rn "@moduledoc false" lib`. For each module that is `alias`ed from outside its own subtree, replace `@moduledoc false` with a real `@moduledoc """..."""` describing intent and contract. Keep `false` for `*HTML`, `*Components`, `Web.Live.*` view plumbing, and similar internal modules.
3. Add missing `@spec` to the public-API functions flagged by `mix docs --warnings-as-errors`.
4. Do NOT bulk-add specs to private helpers — knock-on churn.
Priority: **Medium**, after `ex_doc` is wired. Documentation debt, not a high-priority defect.
### Test strategy
CI gate via `mix docs --warnings-as-errors` once `ex_doc` is added.
---
## L1 — `cond do ... true ->` repetition
Mostly cosmetic. Example in `chat_editor.ex:945-949` re-fetches `Map.get(surface, :title)` thrice. Opportunistic cleanup, not audit-worthy. Touch when working nearby.
Priority: **Low**. No dedicated task.
## L2 — `apply/3` with a parsed atom (informational only)
`lib/bds/search.ex:743-744` already bounds the atom via `Map.fetch(@stemmer_algorithms, normalize_language(language))` before `apply(Stemex, algorithm, [token])`. **Not a dynamic-dispatch hazard** in current code. Optional improvement: add `@allowed_stemmer_atoms Map.values(@stemmer_algorithms)` and a guard for defense-in-depth when the list changes.
Priority: **Low**. No dedicated task.
---
## Suggested fix priority (re-ranked)
| # | Item | Effort | Risk |
|---|---|---|---|
| 1 | H1 delete stray `Logger.info` in `chat_editor.ex:93,98` | tiny | none |
| 2 | H2 externalise `language_names` into `BDS.I18n` as native autonyms (no gettext) | small | low |
| 3 | H4 rewrite `{:ok,_}=…rescue` in `overlay_components.ex`; narrow DB rescues (preserve fallbacks) | medium | medium |
| 4 | M1 extract remaining `shell_live.ex` event clusters (project / locale / native-menu / workspace) | medium | low (delegation preserves behaviour) |
| 5 | M3 `Enum.at` brittle cases in `import_analysis.ex:428` and `rendering/filters.ex:241` | small | low |
| 6 | M4 wire `ex_doc`; flip public-API `@moduledoc false`; close `@spec` gaps | medium | none |
| 7 | H3 consolidate helpers into new `BDS.Values` (with semantic inventory) — **last because: medium effort, high risk for one-liner predicates, partial dedup payoff** | medium | high (trim vs non-trim boundary + truthy accept-set widening at `catalog.ex`) |
Schedule L1/L2 opportunistically when touching those files.
---
## Required verification after each task
Per AGENTS.md, a task is not done until all five are green:
```
mix test # or: xvfb-run mix test on headless Linux
mix compile --warnings-as-errors
mix credo --strict
mix deps.audit
mix dialyzer
```

View File

@@ -1,5 +1,11 @@
defmodule BDS.AI.Chat do defmodule BDS.AI.Chat do
@moduledoc false @moduledoc """
Context for AI chat conversations: start, list, fetch, and delete
conversations, persist surface state, and resolve the effective chat model.
Automatic AI activity is gated by the app's airplane (offline) mode; callers
must respect that gating before invoking model-backed operations.
"""
import Ecto.Query import Ecto.Query
require Logger require Logger
@@ -935,5 +941,5 @@ defmodule BDS.AI.Chat do
defp encode_nullable(nil), do: nil defp encode_nullable(nil), do: nil
defp encode_nullable(value), do: Jason.encode!(value) defp encode_nullable(value), do: Jason.encode!(value)
defp blank?(value), do: value in [nil, ""] defp blank?(value), do: BDS.Values.blank?(value)
end end

View File

@@ -107,5 +107,5 @@ defmodule BDS.AI.Runtime do
end end
end end
defp blank?(value), do: value in [nil, ""] defp blank?(value), do: BDS.Values.blank?(value)
end end

View File

@@ -18,6 +18,7 @@ defmodule BDS.Desktop.ShellLive do
MediaEditor, MediaEditor,
MenuEditor, MenuEditor,
MiscEditor, MiscEditor,
NativeMenuEvents,
OverlayManager, OverlayManager,
ScriptEditor, ScriptEditor,
SettingsEditor, SettingsEditor,
@@ -81,6 +82,8 @@ defmodule BDS.Desktop.ShellLive do
@git_action_events ["git_fetch", "git_pull", "git_push", "git_prune_lfs"] @git_action_events ["git_fetch", "git_pull", "git_push", "git_prune_lfs"]
@native_menu_events NativeMenuEvents.events()
@layout_menu_actions MapSet.new([ @layout_menu_actions MapSet.new([
:toggle_sidebar, :toggle_sidebar,
:toggle_panel, :toggle_panel,
@@ -524,31 +527,8 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))} {:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))}
end end
def handle_event("native_menu_action", %{"action" => action}, socket) do def handle_event(event, params, socket) when event in @native_menu_events do
{:noreply, handle_native_menu_action(socket, action)} NativeMenuEvents.handle(event, params, socket, &handle_native_menu_action/2)
end
def handle_event("titlebar_menu_keydown", %{"key" => key}, socket) do
{:noreply, TitlebarMenu.handle_keydown(socket, key, &handle_native_menu_action/2)}
end
def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do
{:noreply, TitlebarMenu.toggle(socket, group)}
end
def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do
{:noreply, TitlebarMenu.hover(socket, group)}
end
def handle_event("close_titlebar_menu", _params, socket) do
{:noreply, TitlebarMenu.close(socket)}
end
def handle_event("titlebar_menu_action", %{"action" => action}, socket) do
{:noreply,
socket
|> TitlebarMenu.close()
|> handle_native_menu_action(action)}
end end
@impl true @impl true

View File

@@ -90,12 +90,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end end
def handle_event("send_chat_editor_message", _params, socket) do def handle_event("send_chat_editor_message", _params, socket) do
Logger.info("CHAT send_chat_editor_message called, input=#{inspect(socket.assigns.input)}")
{:noreply, do_send_message(socket)} {:noreply, do_send_message(socket)}
end end
def handle_event("abort_chat_editor_message", _params, socket) do def handle_event("abort_chat_editor_message", _params, socket) do
Logger.info("CHAT abort_chat_editor_message called")
{:noreply, do_abort_message(socket)} {:noreply, do_abort_message(socket)}
end end
@@ -581,8 +579,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
def chart_width(_max_value, _value), do: 0 def chart_width(_max_value, _value), do: 0
@spec truthy?(term()) :: boolean() @spec truthy?(term()) :: boolean()
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true def truthy?(value), do: BDS.Values.truthy?(value)
def truthy?(_value), do: false
# ── HEEx components ─────────────────────────────────────────────────────── # ── HEEx components ───────────────────────────────────────────────────────

View File

@@ -303,6 +303,5 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
defp map_value(_map, _key, default), do: default defp map_value(_map, _key, default), do: default
defp truthy?(value) when value in [true, "true", 1, "1", "on"], do: true defp truthy?(value), do: BDS.Values.truthy?(value)
defp truthy?(_value), do: false
end end

View File

@@ -1452,6 +1452,6 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
Map.get(item, :resolution) in ["overwrite", "merge"] Map.get(item, :resolution) in ["overwrite", "merge"]
end end
defp present?(value), do: value not in [nil, ""] defp present?(value), do: BDS.Values.present?(value)
defp blank?(value), do: value in [nil, ""] defp blank?(value), do: BDS.Values.blank?(value)
end end

View File

@@ -310,5 +310,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@spec translate_phase(term()) :: term() @spec translate_phase(term()) :: term()
def translate_phase(other), do: other def translate_phase(other), do: other
defp present?(value), do: value not in [nil, ""] defp present?(value), do: BDS.Values.present?(value)
end end

View File

@@ -284,5 +284,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
def maybe_put_option(opts, _key, nil), do: opts def maybe_put_option(opts, _key, nil), do: opts
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value) def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
defp present?(value), do: value not in [nil, ""] defp present?(value), do: BDS.Values.present?(value)
end end

View File

@@ -0,0 +1,52 @@
defmodule BDS.Desktop.ShellLive.NativeMenuEvents do
@moduledoc """
Native- and titlebar-menu LiveView events extracted from `BDS.Desktop.ShellLive`.
`ShellLive` routes the events listed in `events/0` here via a single delegating
`handle_event/3`, passing its `handle_native_menu_action/2` callback so the
menu-action dispatch logic stays owned by `ShellLive`.
"""
alias BDS.Desktop.ShellLive.TitlebarMenu
@events ~w(
native_menu_action
titlebar_menu_keydown
toggle_titlebar_menu
hover_titlebar_menu
close_titlebar_menu
titlebar_menu_action
)
@doc "Event names handled by this module."
@spec events() :: [String.t()]
def events, do: @events
@typep socket :: Phoenix.LiveView.Socket.t()
@typep native_action :: (socket(), String.t() -> socket())
@spec handle(String.t(), map(), socket(), native_action()) :: {:noreply, socket()}
def handle("native_menu_action", %{"action" => action}, socket, native_action) do
{:noreply, native_action.(socket, action)}
end
def handle("titlebar_menu_keydown", %{"key" => key}, socket, native_action) do
{:noreply, TitlebarMenu.handle_keydown(socket, key, native_action)}
end
def handle("toggle_titlebar_menu", %{"group" => group}, socket, _native_action) do
{:noreply, TitlebarMenu.toggle(socket, group)}
end
def handle("hover_titlebar_menu", %{"group" => group}, socket, _native_action) do
{:noreply, TitlebarMenu.hover(socket, group)}
end
def handle("close_titlebar_menu", _params, socket, _native_action) do
{:noreply, TitlebarMenu.close(socket)}
end
def handle("titlebar_menu_action", %{"action" => action}, socket, native_action) do
{:noreply, socket |> TitlebarMenu.close() |> native_action.(action)}
end
end

View File

@@ -7,12 +7,17 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
import Ecto.Query import Ecto.Query
require Logger require Logger
alias BDS.{I18n, Media, Metadata, Posts, Repo} alias BDS.{I18n, Media, Metadata, Posts, Repo, Slug, Strings}
alias BDS.Media.Media, as: MediaRecord alias BDS.Media.Media, as: MediaRecord
alias BDS.Media.Translation, as: MediaTranslation alias BDS.Media.Translation, as: MediaTranslation
alias BDS.Posts.{Post, PostMedia, Translation} alias BDS.Posts.{Post, PostMedia, Translation}
alias BDS.Tags.Tag alias BDS.Tags.Tag
# Expected data/DB exceptions for the overlay fallbacks below. Narrowed (not a
# broad `rescue error ->`) so programmer bugs (MatchError, FunctionClauseError)
# surface unmodified instead of being swallowed into a fallback value.
@overlay_rescue [Ecto.NoResultsError, Ecto.QueryError, DBConnection.ConnectionError]
embed_templates("overlay_html/*") embed_templates("overlay_html/*")
def context(assigns, tab_title, tab_subtitle) do def context(assigns, tab_title, tab_subtitle) do
@@ -71,8 +76,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
metadata metadata
rescue rescue
error -> error in @overlay_rescue ->
log_overlay_warning("project metadata", error) log_overlay_error("project metadata", error)
%{main_language: "en", blog_languages: []} %{main_language: "en", blog_languages: []}
end end
@@ -140,8 +145,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
select: pm.media_id select: pm.media_id
) )
rescue rescue
error -> error in @overlay_rescue ->
log_overlay_warning("post media ids for #{post_id}", error) log_overlay_error("post media ids for #{post_id}", error)
[] []
end end
@@ -155,8 +160,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
) )
|> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end) |> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end)
rescue rescue
error -> error in @overlay_rescue ->
log_overlay_warning("post translations for #{post_id}", error) log_overlay_error("post translations for #{post_id}", error)
%{} %{}
end end
@@ -168,8 +173,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
) )
|> Map.new(fn {language, status} -> {language, status} end) |> Map.new(fn {language, status} -> {language, status} end)
rescue rescue
error -> error in @overlay_rescue ->
log_overlay_warning("media translations for #{media_id}", error) log_overlay_error("media translations for #{media_id}", error)
%{} %{}
end end
@@ -188,8 +193,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
_other -> metadata.main_language || "en" _other -> metadata.main_language || "en"
end end
rescue rescue
error -> error in @overlay_rescue ->
log_overlay_warning("post source language for #{post_id}", error) log_overlay_error("post source language for #{post_id}", error)
metadata.main_language || "en" metadata.main_language || "en"
end end
@@ -199,21 +204,16 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
_other -> metadata.main_language || "en" _other -> metadata.main_language || "en"
end end
rescue rescue
error -> error in @overlay_rescue ->
log_overlay_warning("media source language for #{media_id}", error) log_overlay_error("media source language for #{media_id}", error)
metadata.main_language || "en" metadata.main_language || "en"
end end
defp source_language(_tab, metadata), do: metadata.main_language || "en" defp source_language(_tab, metadata), do: metadata.main_language || "en"
defp language_names do defp language_names do
%{ I18n.supported_languages()
"en" => "English", |> Enum.into(%{}, fn language -> {language.code, I18n.language_name(language.code)} end)
"de" => "Deutsch",
"fr" => "Francais",
"it" => "Italiano",
"es" => "Espanol"
}
end end
defp language_flags do defp language_flags do
@@ -244,7 +244,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
%{ %{
key: "slug", key: "slug",
label: BDS.Gettext.lgettext(page_language, "ui", "Slug"), label: BDS.Gettext.lgettext(page_language, "ui", "Slug"),
current_value: post.slug || slugify(post.title || title), current_value: post.slug || Slug.slugify(post.title || title),
suggested_value: "", suggested_value: "",
locked: post.status == :published, locked: post.status == :published,
loading: true loading: true
@@ -255,8 +255,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
[] []
end end
rescue rescue
error -> error in @overlay_rescue ->
log_overlay_warning("post AI fields for #{post_id}", error) log_overlay_error("post AI fields for #{post_id}", error)
[] []
end end
@@ -294,8 +294,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
[] []
end end
rescue rescue
error -> error in @overlay_rescue ->
log_overlay_warning("media AI fields for #{media_id}", error) log_overlay_error("media AI fields for #{media_id}", error)
[] []
end end
@@ -326,8 +326,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: reference_list reference_list: reference_list
} }
rescue rescue
error -> error in @overlay_rescue ->
log_overlay_warning("delete media details for #{media_id}", error) log_overlay_error("delete media details for #{media_id}", error)
%{ %{
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Media"), title: BDS.Gettext.lgettext(page_language, "ui", "Delete Media"),
@@ -349,8 +349,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: [] reference_list: []
} }
rescue rescue
error -> error in @overlay_rescue ->
log_overlay_warning("delete tag details", error) log_overlay_error("delete tag details", error)
%{ %{
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"), title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
@@ -388,8 +388,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
message: BDS.Gettext.lgettext(page_language, "ui", "Cannot be undone.") message: BDS.Gettext.lgettext(page_language, "ui", "Cannot be undone.")
} }
rescue rescue
error -> error in @overlay_rescue ->
log_overlay_warning("merge tag details for project #{project_id}", error) log_overlay_error("merge tag details for project #{project_id}", error)
%{ %{
target: "tag", target: "tag",
@@ -402,20 +402,10 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
defp canonical_post_url(post) do defp canonical_post_url(post) do
timestamp = post.published_at || post.updated_at || System.system_time(:millisecond) timestamp = post.published_at || post.updated_at || System.system_time(:millisecond)
date = DateTime.from_unix!(timestamp, :millisecond) date = DateTime.from_unix!(timestamp, :millisecond)
"/#{date.year}/#{pad2(date.month)}/#{pad2(date.day)}/#{post.slug || post.id}" "/#{date.year}/#{Strings.pad2(date.month)}/#{Strings.pad2(date.day)}/#{post.slug || post.id}"
end end
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0") defp log_overlay_error(context, error) do
Logger.error("overlay component fallback for #{context}: #{Exception.message(error)}")
defp slugify(value) do
value
|> to_string()
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.trim("-")
end
defp log_overlay_warning(context, error) do
Logger.warning("overlay component fallback for #{context}: #{Exception.message(error)}")
end end
end end

View File

@@ -300,5 +300,5 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
"#{rounded}%" "#{rounded}%"
end end
defp present?(value), do: value not in [nil, ""] defp present?(value), do: BDS.Values.present?(value)
end end

View File

@@ -227,8 +227,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
active_language == canonical_language or not Map.has_key?(translations, active_language) active_language == canonical_language or not Map.has_key?(translations, active_language)
end end
defp truthy?(value) when value in [true, "true", "on", 1, "1"], do: true defp truthy?(value), do: BDS.Values.truthy?(value)
defp truthy?(_value), do: false
defp blank_to_nil(value) do defp blank_to_nil(value) do
value value

View File

@@ -201,18 +201,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
defp canonical_preview_path(created_at_ms, slug) do defp canonical_preview_path(created_at_ms, slug) do
datetime = DateTime.from_unix!(created_at_ms, :millisecond) datetime = DateTime.from_unix!(created_at_ms, :millisecond)
"/#{datetime.year}/#{pad2(datetime.month)}/#{pad2(datetime.day)}/#{slug || ""}" "/#{datetime.year}/#{BDS.Strings.pad2(datetime.month)}/#{BDS.Strings.pad2(datetime.day)}/#{slug || ""}"
end end
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
defp maybe_put_query(query, _key, false), do: query defp maybe_put_query(query, _key, false), do: query
defp maybe_put_query(query, _key, nil), do: query defp maybe_put_query(query, _key, nil), do: query
defp maybe_put_query(query, key, value), do: Map.put(query, key, value) defp maybe_put_query(query, key, value), do: Map.put(query, key, value)
def truthy?(value) when value in [true, "true", "on", 1, "1"], do: true @spec truthy?(term()) :: boolean()
@spec truthy?(term()) :: term() def truthy?(value), do: BDS.Values.truthy?(value)
def truthy?(_value), do: false
@spec blank?(term()) :: term() @spec blank?(term()) :: term()
def blank?(value), do: blank_to_nil(value) == nil def blank?(value), do: blank_to_nil(value) == nil

View File

@@ -312,7 +312,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp normalize_endpoint_result({:ok, _endpoint}), do: :ok defp normalize_endpoint_result({:ok, _endpoint}), do: :ok
defp normalize_endpoint_result({:error, reason}), do: {:error, reason} defp normalize_endpoint_result({:error, reason}), do: {:error, reason}
defp truthy?(value), do: value in [true, "true", "on", "1", 1] defp truthy?(value), do: BDS.Values.truthy?(value)
defp blank_to_nil(nil), do: nil defp blank_to_nil(nil), do: nil

View File

@@ -77,7 +77,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.EditorSettings do
} }
end end
defp truthy?(value), do: value in [true, "true", "on", "1", 1] defp truthy?(value), do: BDS.Values.truthy?(value)
defp boolean_string(true), do: "true" defp boolean_string(true), do: "true"
defp boolean_string(false), do: "false" defp boolean_string(false), do: "false"
end end

View File

@@ -187,7 +187,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end) end)
end end
defp truthy?(value), do: value in [true, "true", "on", "1", 1] defp truthy?(value), do: BDS.Values.truthy?(value)
defp blank_to_nil(nil), do: nil defp blank_to_nil(nil), do: nil

View File

@@ -94,7 +94,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
} }
end end
defp truthy?(value), do: value in [true, "true", "on", "1", 1] defp truthy?(value), do: BDS.Values.truthy?(value)
defp parse_integer(nil, fallback), do: fallback defp parse_integer(nil, fallback), do: fallback
defp parse_integer(value, _fallback) when is_integer(value), do: value defp parse_integer(value, _fallback) when is_integer(value), do: value

View File

@@ -1,5 +1,11 @@
defmodule BDS.Embeddings do defmodule BDS.Embeddings do
@moduledoc false @moduledoc """
Context for post embeddings: build and refresh the per-project vector index,
sync individual posts, report indexing progress, and compute similarity.
The backing index is persisted to disk and rebuilt on demand; a failed persist
is retried by the next reindex.
"""
import Ecto.Query import Ecto.Query
require Logger require Logger

View File

@@ -108,7 +108,10 @@ defmodule BDS.Embeddings.Backends.Neural do
{:reply, error, state} {:reply, error, state}
end end
rescue rescue
exception -> # Only genuine Nx/serving runtime errors become {:error, _}. Programmer bugs
# (MatchError, FunctionClauseError, …) are not caught here so they crash the
# supervised GenServer and surface unmodified instead of being repackaged.
exception in [ArgumentError, RuntimeError] ->
{:reply, {:error, Exception.message(exception)}, state} {:reply, {:error, Exception.message(exception)}, state}
end end

View File

@@ -347,8 +347,10 @@ defmodule BDS.Embeddings.Index do
:ok :ok
rescue rescue
exception -> exception ->
Logger.debug( # Logged at :error (not :debug) so a real persist failure — or a programmer
"swallowed embeddings index persist error for #{project_id}: #{inspect(exception)}" # bug surfacing as an exception — is visible; next reindex retries the save.
Logger.error(
"embeddings index persist failed for #{project_id}: #{inspect(exception)}"
) )
:ok :ok
@@ -392,8 +394,8 @@ defmodule BDS.Embeddings.Index do
end end
rescue rescue
exception -> exception ->
Logger.debug( Logger.error(
"swallowed embeddings index load_from_disk error for #{project_id}: #{inspect(exception)}" "embeddings index load_from_disk failed for #{project_id}: #{inspect(exception)}"
) )
:error :error

View File

@@ -1,5 +1,11 @@
defmodule BDS.Generation do defmodule BDS.Generation do
@moduledoc false @moduledoc """
Context for static-site generation: plan, generate, and validate a project's
rendered output, apply validation results, and write generated files.
Generation is organised into sections (defaulting to `:core`) so callers can
regenerate a subset of the site.
"""
import Ecto.Query import Ecto.Query

View File

@@ -1,5 +1,12 @@
defmodule BDS.Git do defmodule BDS.Git do
@moduledoc false @moduledoc """
Git operations for a project's repository: initialize, inspect status and
diffs, browse history, and run network actions (fetch/pull/push) with
Git LFS support.
Local and network operations use separate timeouts; network calls are gated by
the app's offline mode at the call sites that invoke them.
"""
alias BDS.Projects alias BDS.Projects
require Logger require Logger

View File

@@ -28,6 +28,15 @@ defmodule BDS.I18n do
"ES" => "🇪🇸" "ES" => "🇪🇸"
} }
# Native autonyms — a language's self-name, not translated into the UI locale.
@language_names %{
"en" => "English",
"de" => "Deutsch",
"fr" => "Français",
"it" => "Italiano",
"es" => "Español"
}
@default_language "en" @default_language "en"
@default_format_locale "en-US" @default_format_locale "en-US"
@@ -35,6 +44,12 @@ defmodule BDS.I18n do
def default_language, do: @default_language def default_language, do: @default_language
@doc "Native autonym (self-name) for a supported language code, e.g. \"de\" -> \"Deutsch\"."
def language_name(language) do
code = resolve_render_locale(language)
Map.get(@language_names, code, code)
end
def normalize_language(language) do def normalize_language(language) do
language language
|> normalize_locale_prefix() |> normalize_locale_prefix()

View File

@@ -423,13 +423,14 @@ defmodule BDS.ImportAnalysis do
end end
defp parse_macro_params(raw_params) do defp parse_macro_params(raw_params) do
Regex.scan(@param_regex, raw_params) @param_regex
|> Enum.map(fn captures -> |> Regex.scan(raw_params)
key = Enum.at(captures, 1) |> Map.new(fn [_full, key | value_groups] ->
value = Enum.at(captures, 2) || Enum.at(captures, 3) || Enum.at(captures, 4) || "" # Exactly one of the quoted/unquoted value groups participates (the others
# are "" or dropped by Regex.scan); an empty quoted value falls back to "".
value = value_groups |> Enum.reject(&(&1 == "")) |> List.last() |> Kernel.||("")
{key, value} {key, value}
end) end)
|> Map.new()
end end
defp serialize_params(params) when params == %{}, do: "" defp serialize_params(params) when params == %{}, do: ""

View File

@@ -1,5 +1,11 @@
defmodule BDS.Media do defmodule BDS.Media do
@moduledoc false @moduledoc """
Context for media assets and their translations: import, update, replace, and
delete media, and manage per-language metadata translations.
Each operation keeps the database record, the media file on disk, and the
derived metadata in sync.
"""
import BDS.Media.FileOps, import BDS.Media.FileOps,
only: [ only: [

View File

@@ -1,5 +1,12 @@
defmodule BDS.Posts do defmodule BDS.Posts do
@moduledoc false @moduledoc """
Context for blog posts and their translations: create, update, publish, and
discard changes, plus editor-body resolution.
Published posts keep their body on the filesystem rather than in the database,
so `editor_body/1` reads from the post's file when no in-memory content is
present. Changes are kept in sync with the filesystem and the embeddings index.
"""
import Ecto.Query import Ecto.Query
import BDS.MapUtils, only: [attr: 2, maybe_put: 3] import BDS.MapUtils, only: [attr: 2, maybe_put: 3]

View File

@@ -1,5 +1,11 @@
defmodule BDS.Preview do defmodule BDS.Preview do
@moduledoc false @moduledoc """
GenServer that runs the local, self-contained preview server for a project.
Starts/stops per-project preview rendering and serves rendered pages and draft
previews via `request/2` and `preview_draft/3`. Generated HTML references only
local, package-bundled assets — never remote CDN JS/CSS.
"""
use GenServer use GenServer
require Logger require Logger
@@ -22,10 +28,12 @@ defmodule BDS.Preview do
@listen_retry_attempts 10 @listen_retry_attempts 10
@listen_retry_delay_ms 50 @listen_retry_delay_ms 50
@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 start_preview(String.t()) :: {:ok, map()} | {:error, term()}
def start_preview(project_id) when is_binary(project_id) do def start_preview(project_id) when is_binary(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
@@ -35,6 +43,7 @@ defmodule BDS.Preview do
) )
end end
@spec ensure_preview(String.t()) :: {:ok, map()} | {:error, term()}
def ensure_preview(project_id) when is_binary(project_id) do def ensure_preview(project_id) when is_binary(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
@@ -44,6 +53,7 @@ defmodule BDS.Preview do
) )
end end
@spec base_url() :: String.t()
def base_url do def base_url do
case :ets.whereis(@server_table) do case :ets.whereis(@server_table) do
:undefined -> "http://#{@host}:#{@preferred_port}" :undefined -> "http://#{@host}:#{@preferred_port}"
@@ -56,10 +66,12 @@ defmodule BDS.Preview do
end end
end end
@spec stop_preview(String.t()) :: :ok | {:error, term()}
def stop_preview(project_id) when is_binary(project_id) do def stop_preview(project_id) when is_binary(project_id) do
GenServer.call(__MODULE__, {:stop_preview, project_id}) GenServer.call(__MODULE__, {:stop_preview, project_id})
end end
@spec request(String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def request(project_id, request_path) when is_binary(project_id) and is_binary(request_path) do def request(project_id, request_path) when is_binary(project_id) and is_binary(request_path) do
{path, query_params} = split_request_path(request_path) {path, query_params} = split_request_path(request_path)
@@ -68,6 +80,7 @@ defmodule BDS.Preview do
end) end)
end end
@spec preview_draft(String.t(), String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def preview_draft(project_id, request_path, post_id) def preview_draft(project_id, request_path, post_id)
when is_binary(project_id) and is_binary(request_path) and is_binary(post_id) do when is_binary(project_id) and is_binary(request_path) and is_binary(post_id) do
{_path, query_params} = split_request_path(request_path) {_path, query_params} = split_request_path(request_path)

View File

@@ -238,26 +238,27 @@ defmodule BDS.Rendering.Filters do
path_part |> String.replace(~r/\.html?$/i, "") path_part |> String.replace(~r/\.html?$/i, "")
match = Regex.run(~r|^/?post/([a-z0-9-]+(?:\.html?)?)$|i, path_part) -> match = Regex.run(~r|^/?post/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "") slug_from_match(match, canonical_post_paths)
Map.get(canonical_post_paths, slug)
match = Regex.run(~r|^/?post/\d{4}/\d{1,2}/([a-z0-9-]+(?:\.html?)?)$|i, path_part) -> match = Regex.run(~r|^/?post/\d{4}/\d{1,2}/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "") slug_from_match(match, canonical_post_paths)
Map.get(canonical_post_paths, slug)
match = Regex.run(~r|^/?posts/([a-z0-9-]+(?:\.html?)?)$|i, path_part) -> match = Regex.run(~r|^/?posts/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "") slug_from_match(match, canonical_post_paths)
Map.get(canonical_post_paths, slug)
match = Regex.run(~r|^/?posts/\d{4}/\d{1,2}/([a-z0-9-]+(?:\.html?)?)$|i, path_part) -> match = Regex.run(~r|^/?posts/\d{4}/\d{1,2}/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "") slug_from_match(match, canonical_post_paths)
Map.get(canonical_post_paths, slug)
true -> true ->
nil nil
end end
end end
defp slug_from_match([_full, captured], canonical_post_paths) do
slug = String.replace(captured, ~r/\.html?$/i, "")
Map.get(canonical_post_paths, slug)
end
defp normalize_media_src(raw_src, canonical_media_paths) do defp normalize_media_src(raw_src, canonical_media_paths) do
cond do cond do
raw_src == "" -> raw_src == "" ->

View File

@@ -120,7 +120,7 @@ defmodule BDS.Rendering.LinksAndLanguages do
defp post_date_path_parts(created_at) do defp post_date_path_parts(created_at) do
datetime = Persistence.from_unix_ms!(created_at) datetime = Persistence.from_unix_ms!(created_at)
year_month_path_parts(datetime) ++ [pad2(datetime.day)] year_month_path_parts(datetime) ++ [BDS.Strings.pad2(datetime.day)]
end end
defp year_month_path_parts(created_at) when is_integer(created_at) do defp year_month_path_parts(created_at) when is_integer(created_at) do
@@ -130,13 +130,11 @@ defmodule BDS.Rendering.LinksAndLanguages do
end end
defp year_month_path_parts(%DateTime{} = datetime) do defp year_month_path_parts(%DateTime{} = datetime) do
[Integer.to_string(datetime.year), pad2(datetime.month)] [Integer.to_string(datetime.year), BDS.Strings.pad2(datetime.month)]
end end
defp normalize_language_prefix(prefix) when is_binary(prefix) and prefix != "", defp normalize_language_prefix(prefix) when is_binary(prefix) and prefix != "",
do: String.trim_trailing(prefix, "/") do: String.trim_trailing(prefix, "/")
defp normalize_language_prefix(_prefix), do: "" defp normalize_language_prefix(_prefix), do: ""
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
end end

View File

@@ -160,7 +160,7 @@ defmodule BDS.Scripting.Capabilities.Util do
def truthy?(value), do: value in [true, "true", 1, 1.0, "1"] def truthy?(value), do: value in [true, "true", 1, 1.0, "1"]
@spec pad2(integer()) :: String.t() @spec pad2(integer()) :: String.t()
def pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0") defdelegate pad2(value), to: BDS.Strings
@spec blank_to_nil(term()) :: term() @spec blank_to_nil(term()) :: term()
def blank_to_nil(nil), do: nil def blank_to_nil(nil), do: nil

9
lib/bds/strings.ex Normal file
View File

@@ -0,0 +1,9 @@
defmodule BDS.Strings do
@moduledoc """
Small string-formatting helpers shared across the codebase.
"""
@doc "Zero-pads an integer to at least two digits, e.g. `3 -> \"03\"`."
@spec pad2(integer()) :: String.t()
def pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
end

View File

@@ -1,5 +1,8 @@
defmodule BDS.Tags do defmodule BDS.Tags do
@moduledoc false @moduledoc """
Context for post tags: create, list, update, rename, delete, and merge tags,
and keep them in sync with posts and the on-disk `tags.json`.
"""
import Ecto.Query import Ecto.Query

View File

@@ -194,5 +194,5 @@ defmodule BDS.UI.Dashboard do
if blank?(post.title), do: post.slug || "", else: post.title if blank?(post.title), do: post.slug || "", else: post.title
end end
defp blank?(value), do: value in [nil, ""] defp blank?(value), do: BDS.Values.blank?(value)
end end

View File

@@ -1125,5 +1125,5 @@ defmodule BDS.UI.Sidebar do
defp media_size_label(_size), do: "0 B" defp media_size_label(_size), do: "0 B"
defp present?(value), do: value not in [nil, ""] defp present?(value), do: BDS.Values.present?(value)
end end

27
lib/bds/values.ex Normal file
View File

@@ -0,0 +1,27 @@
defmodule BDS.Values do
@moduledoc """
Canonical scalar predicates for blank/present/truthy checks.
These operate on individual values (not maps), which is why they live here
rather than in `BDS.MapUtils`. `present?/1` and `blank?/1` use the same
non-trimming contract as `BDS.MapUtils.blank_to_nil/1`: a whitespace-only
string such as `" "` is considered **present**. Call sites that need
trim-aware semantics must compose `String.trim/1` themselves.
"""
@doc "Maps `nil` and `\"\"` to `nil`, everything else to itself (non-trimming)."
@spec blank_to_nil(term()) :: term()
defdelegate blank_to_nil(value), to: BDS.MapUtils
@doc "True when `value` is neither `nil` nor `\"\"` (non-trimming)."
@spec present?(term()) :: boolean()
def present?(value), do: blank_to_nil(value) != nil
@doc "True when `value` is `nil` or `\"\"` (non-trimming)."
@spec blank?(term()) :: boolean()
def blank?(value), do: blank_to_nil(value) == nil
@doc "True for the canonical truthy set: `true`, `\"true\"`, `\"on\"`, `1`, `1.0`, `\"1\"`."
@spec truthy?(term()) :: boolean()
def truthy?(value), do: value in [true, "true", "on", 1, 1.0, "1"]
end

View File

@@ -55,7 +55,8 @@ defmodule BDS.MixProject do
{:lazy_html, ">= 0.1.0", only: :test}, {:lazy_html, ">= 0.1.0", only: :test},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:ex_doc, "~> 0.34", only: :dev, runtime: false}
] ]
end end

View File

@@ -15,6 +15,7 @@
"decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"}, "decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
"desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"}, "desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"earmark_parser": {:hex, :earmark_parser, "1.4.45", "cba8369ab2a1342e419bc2760eec731b17be828941dcf494045d44766227e1d5", [:mix], [], "hexpm", "d3ec045bf122965db20c0bdb420e19ee1415843135327124918473feb4b328e8"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
@@ -23,6 +24,7 @@
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"}, "ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
"ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"},
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"}, "ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"}, "ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
"exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"}, "exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"},
@@ -42,6 +44,9 @@
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"}, "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"}, "liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"},
"luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"}, "luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"},
"mdex": {:hex, :mdex, "0.13.1", "2af3da1e0ec1594d9fc4c0134031a0b48397092963eef87448a7a1c2b9332663", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.2", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "99e934e977ea4045914870616baae8d4e1513aa0309f769ed65a681ebe5e0f02"}, "mdex": {:hex, :mdex, "0.13.1", "2af3da1e0ec1594d9fc4c0134031a0b48397092963eef87448a7a1c2b9332663", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.2", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "99e934e977ea4045914870616baae8d4e1513aa0309f769ed65a681ebe5e0f02"},
"mdex_native": {:hex, :mdex_native, "0.2.2", "adc230643a173b4a2b3593c450f56c41e51714c51f59bb8e528993810373cb92", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f16b0548dca63b91c134316cc89c1160119e8f308db57bcc5fc058206a7df32d"}, "mdex_native": {:hex, :mdex_native, "0.2.2", "adc230643a173b4a2b3593c450f56c41e51714c51f59bb8e528993810373cb92", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f16b0548dca63b91c134316cc89c1160119e8f308db57bcc5fc058206a7df32d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},

View File

@@ -1,6 +1,20 @@
defmodule BDS.I18nTest do defmodule BDS.I18nTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
test "language_name/1 returns a non-empty native autonym for exactly the supported codes" do
codes = Enum.map(BDS.I18n.supported_languages(), & &1.code)
for code <- codes do
assert BDS.I18n.language_name(code) not in [nil, ""]
end
assert BDS.I18n.language_name("de") == "Deutsch"
assert BDS.I18n.language_name("fr") == "Français"
assert BDS.I18n.language_name("es") == "Español"
# Unsupported codes fall back to the resolved render locale (default "en").
assert BDS.I18n.language_name("pt-BR") == "English"
end
test "supported languages, normalization, and UI locale resolution follow the spec" do test "supported languages, normalization, and UI locale resolution follow the spec" do
assert Enum.map(BDS.I18n.supported_languages(), & &1.code) == ["en", "de", "fr", "it", "es"] assert Enum.map(BDS.I18n.supported_languages(), & &1.code) == ["en", "de", "fr", "it", "es"]

12
test/bds/strings_test.exs Normal file
View File

@@ -0,0 +1,12 @@
defmodule BDS.StringsTest do
use ExUnit.Case, async: true
alias BDS.Strings
test "pad2/1 zero-pads to at least two digits" do
assert Strings.pad2(0) == "00"
assert Strings.pad2(3) == "03"
assert Strings.pad2(12) == "12"
assert Strings.pad2(123) == "123"
end
end

View File

@@ -552,7 +552,13 @@ defmodule BDS.UI.ShellTest do
"phx-window-keydown={if(@titlebar_menu_group, do: \"titlebar_menu_keydown\")}" "phx-window-keydown={if(@titlebar_menu_group, do: \"titlebar_menu_keydown\")}"
assert template =~ "window-titlebar-menu-group" assert template =~ "window-titlebar-menu-group"
assert live_ex =~ ~s(def handle_event("titlebar_menu_keydown")
# titlebar/native menu events are routed from ShellLive to NativeMenuEvents.
native_menu_ex =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/native_menu_events.ex")
assert live_ex =~ "@native_menu_events"
assert native_menu_ex =~ ~s(def handle("titlebar_menu_keydown")
assert live_ex =~ "titlebar_menu_item_index" assert live_ex =~ "titlebar_menu_item_index"
end end

51
test/bds/values_test.exs Normal file
View File

@@ -0,0 +1,51 @@
defmodule BDS.ValuesTest do
use ExUnit.Case, async: true
alias BDS.Values
describe "present?/1 and blank?/1 (non-trimming)" do
test "nil and empty string are blank" do
assert Values.blank?(nil)
assert Values.blank?("")
refute Values.present?(nil)
refute Values.present?("")
end
test "whitespace-only strings are PRESENT (non-trimming contract)" do
# Crucial: matches BDS.MapUtils.blank_to_nil/1 — a trim-aware caller must
# compose String.trim/1 itself.
assert Values.present?(" ")
assert Values.present?("\n")
refute Values.blank?(" ")
refute Values.blank?("\n")
end
test "non-empty values are present" do
for v <- ["x", 0, 1, 1.0, false, true] do
assert Values.present?(v)
refute Values.blank?(v)
end
end
end
describe "truthy?/1" do
test "accepts the canonical truthy set" do
for v <- [true, "true", "on", 1, 1.0, "1"] do
assert Values.truthy?(v)
end
end
test "rejects everything else" do
for v <- [false, "false", "off", 0, "0", nil, "", "yes", 2] do
refute Values.truthy?(v)
end
end
end
test "blank_to_nil/1 delegates to MapUtils (non-trimming)" do
assert Values.blank_to_nil(nil) == nil
assert Values.blank_to_nil("") == nil
assert Values.blank_to_nil(" ") == " "
assert Values.blank_to_nil("x") == "x"
end
end