Compare commits

..

14 Commits

Author SHA1 Message Date
f7a4a9512c fix: persist a2ui surfaces in the database for chats to re-hydrate on
opening an old chat, unless manually dismissed
2026-05-27 20:13:33 +02:00
141c2bfc89 removed fixed codesmell document 2026-05-27 19:20:43 +02:00
a5ac74db91 fix(style): add missing @impl true to all handle_call clauses in Publishing GenServer (CSM-036) 2026-05-27 19:19:50 +02:00
beca4d992f fix(docs): document UILocale process-dict invariant and add enforcement tests (CSM-035) 2026-05-27 19:16:42 +02:00
9e6d93a4b3 fix(safety): replace File.read! with File.read and error-tuple handling in preview_assets and templates (CSM-034) 2026-05-27 19:10:13 +02:00
e29dfb490a fix(perf): replace Enum.each + individual inserts with preloaded keys and batch upsert in embeddings (CSM-033) 2026-05-27 19:03:21 +02:00
f2b340ba86 fix(style): replace Map.get with dot access and pattern matching where keys are guaranteed (CSM-032) 2026-05-27 18:33:42 +02:00
d18e0ef7f2 fix(rendering): replace inline try/rescue with with-chains and safe_liquex_render helpers (CSM-031) 2026-05-27 18:12:23 +02:00
2d796cee83 updated specgaps for A3-1 2026-05-27 17:56:19 +02:00
b052d59376 fix(fs): handle File.mkdir_p errors and remove bang variants in sidecars and release packaging (CSM-030) 2026-05-11 20:25:06 +02:00
4a089b0856 fix(perf): bind length/1 to variables before loop bodies in route paths and sidebar (CSM-029) 2026-05-11 20:18:56 +02:00
2632649cdc fix(rendering): replace raising read_template_file with try_read in macro templates (CSM-028) 2026-05-11 20:09:49 +02:00
782511d523 fix(templates): replace equality check with pattern matching in rewrite_template_file (CSM-027) 2026-05-11 20:06:15 +02:00
1cb59d7a78 chore: update the spec gaps with decisionm points 2026-05-11 12:19:16 +02:00
49 changed files with 1378 additions and 763 deletions

View File

@@ -7,7 +7,8 @@
"Bash(mix ecto.migrate)",
"Bash(git add *)",
"Bash(git push *)",
"Bash(git -C /Users/gb/Projects/bDS2 status)"
"Bash(git -C /Users/gb/Projects/bDS2 status)",
"Bash(git status *)"
]
}
}

View File

@@ -1,509 +0,0 @@
# bDS2 Elixir Anti-Pattern & Best-Practice Audit
> Audited: 2026-05-06
> Scope: Elixir application, Phoenix LiveView UI, Ecto DB layer, Desktop (wx) integration, Rendering/Generation pipelines
---
## How to use this file
1. Pick a section.
2. Search the codebase for the file/line references.
3. Write a failing test that reproduces the issue.
4. Fix the code.
5. Run the full test suite and `mix dialyzer`.
6. Delete the item from this file.
---
## Critical (Fix Immediately)
### ~~CSM-001 — Atom Table Exhaustion Vulnerability~~ ✅ FIXED
- **Fixed:** 2026-05-06
- **What was done:**
- Added `BDS.MapUtils.safe_atomize_key/1` and `BDS.MapUtils.safe_atomize_keys/1` — uses `String.to_existing_atom/1` with rescue fallback to keep unknown keys as strings.
- Replaced all 6 affected `String.to_atom` call sites:
- `lib/bds/import_definitions.ex``atomize_keys/1``MapUtils.safe_atomize_keys/1`
- `lib/bds/import_execution.ex``normalize_report/1``MapUtils.safe_atomize_keys/1`
- `lib/bds/ai/catalog.ex``atomize_map_keys/1``MapUtils.safe_atomize_keys/1`, `parse_modality/1``MapUtils.safe_atomize_key/1`
- `lib/bds/ai/chat_tools.ex``metadata_attrs/2``MapUtils.safe_atomize_key/1`
- `lib/bds/desktop/automation.ex``atomize_map/1``MapUtils.safe_atomize_keys/1`
- Replaced lower-risk `String.to_atom` with `String.to_existing_atom/1`:
- `lib/bds/ui/menu_bar.ex` — sidebar view and singleton editor command IDs
- `lib/bds/ui/workbench.ex``normalize_type/1`
- `lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex``map_value/3`
- `lib/bds/release_packaging.ex``normalize_platform/1`
- Updated `test/bds/bounded_atoms_test.exs` to enforce no `String.to_atom` on dynamic data (replaced old `String.to_existing_atom` ban).
---
### ~~CSM-002 — Search Loads Entire Tables into Memory~~ ✅ FIXED
- **Fixed:** 2026-05-07
- **What was done:**
- Replaced `search_posts/3` and `search_media/3` with SQL-level filtering and pagination.
- Blank queries now use pure Ecto queries with `where` clauses for status, language, year/month, date range, tags, categories, and missing translations.
- Non-blank (FTS) queries use a CTE (`WITH fts_results AS (...)`) to preserve `bm25` ordering, joined with the posts/media table, with all filters applied in SQL.
- Tag and category overlap filtering uses `json_each` in `EXISTS` subqueries.
- Missing-translation filtering uses a `NOT EXISTS` correlated subquery.
- Count uses `select count` + `Repo.one` instead of `length(all_records)`.
- Pagination uses SQL `LIMIT`/`OFFSET` instead of `Enum.drop`/`Enum.take`.
- Removed all old Elixir-side filter helpers: `candidate_post_ids`, `load_posts_in_order`, `filter_posts`, `paginate`, `matches_status?`, `matches_overlap?`, etc.
- Added comprehensive tests for blank-query and non-blank-query filtering across all filter dimensions.
---
### ~~CSM-003 — Non-Atomic Side Effects in Post CRUD~~ ✅ FIXED
- **Fixed:** 2026-05-07
- **What was done:**
- Replaced all 11 `Repo.delete!` call sites with `Repo.delete` + `{:error, _}` handling:
- `lib/bds/posts.ex``delete_post/1`
- `lib/bds/scripts.ex``delete_script/1`
- `lib/bds/media.ex``delete_media/1`, `delete_media_translation/3`
- `lib/bds/templates.ex``delete_template/2`, `remove_orphan_templates/2`
- `lib/bds/tags.ex``delete_tag/1`, `merge_tags/2`
- `lib/bds/projects.ex``delete_project/1`
- `lib/bds/posts/translations.ex``delete_post_translation/1`
- `lib/bds/posts/translation_validation.ex``fix_invalid_database_row/1`
- Reordered `delete_post/1` to perform `Repo.delete` first, then clean up files/embeddings/search/links. Side effects now only run after DB commit succeeds.
- Same reordering applied to `delete_script/1`, `delete_media/1`, `delete_template/2`, and `delete_post_translation/1`.
- `delete_media/1` now wraps translation + media deletes in a `Repo.transaction` for atomicity.
- Tags and projects already used `Repo.transaction`; replaced inner `Repo.delete!` with `Repo.delete` + `Repo.rollback` on error.
- Added tests for delete atomicity and not-found handling.
---
### ~~CSM-004 — Blocking `init/1` + Missing `terminate/2` in Job Runner~~ ✅ FIXED
- **Fixed:** 2026-05-08
- **What was done:**
- Moved `JobStore.attach_runner/2` from `init/1` to a new `handle_continue(:attach_and_start)` callback, so supervisor startup is no longer blocked by the synchronous call.
- Added `terminate/2` callback that calls `JobStore.detach_runner/2` (with `try/catch` for shutdown safety), centralizing cleanup that was previously scattered across individual exit paths.
- Added `handle_info({:EXIT, _pid, _reason})` clause to handle trapped exit signals from linked processes.
- Removed redundant inline `detach_runner` calls from `handle_call(:cancel)`, task result handler, and `:DOWN` handler — `terminate/2` now handles all detach cleanup.
- Changed `restart: :temporary` since job runners are one-shot processes that should not auto-restart on failure.
- Added `@impl true` to all `handle_info` clauses.
- Fixed pre-existing bug in `JobStore.detach_runner` handler where `update_in/2` macro result was incorrectly double-wrapped, corrupting state.
- Added test: start a runner, kill it externally (not via cancel), assert `JobStore` no longer contains the dead PID.
---
### ~~CSM-005 — Client-Side Filtering of Entire Tables~~ ✅ FIXED
- **Fixed:** 2026-05-08
- **What was done:**
- **Sidebar** (`lib/bds/ui/sidebar.ex`):
- Removed `list_posts/1` and `list_media/1` that loaded all records into memory.
- Replaced `apply_post_filters/1` and `apply_media_filters/1` (Elixir-side filtering) with SQL `WHERE` clauses using Ecto dynamic queries and SQLite `json_each` fragments.
- Page/non-page split now uses `EXISTS (SELECT 1 FROM json_each(categories) WHERE lower(value) = 'page')` in SQL.
- Search, year/month, tag, and category filters all push to SQL via `maybe_where_search`, `maybe_where_year`, `maybe_where_month`, `maybe_where_all_tags`, `maybe_where_all_categories`.
- Aggregate queries (`year_month_counts`, `available_tags`, `available_categories`) use `Ecto.Adapters.SQL.query!` with `json_each` cross-joins, `GROUP BY`, and `DISTINCT`.
- Pagination uses SQL `LIMIT` instead of `Enum.take`.
- `tag_count/1` replaces `list_tags/1` + `length/1` with `Repo.one(select: count(tag.id))`.
- Fixed `group_posts/1` O(n²) `acc.draft ++ [post]` pattern — now uses `Enum.group_by/2` (also fixes CSM-024).
- **Tags** (`lib/bds/tags.ex`):
- `posts_with_tag/2` now uses `EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)` instead of loading all posts.
- `posts_with_any_tag/2` now uses `json_each` cross-join with a JSON parameter for the tag name list.
- `post_tag_names/1` now selects only the `tags` column instead of loading full post records.
- **Dashboard** (`lib/bds/ui/dashboard.ex`):
- `post_stats` uses `GROUP BY post.status, SELECT {status, count(id)}` — no longer loads all posts.
- `media_stats` uses `SELECT count(id), coalesce(sum(size), 0)` and a separate image count query with `LIKE 'image/%'`.
- `tag_cloud_items` and `category_counts` use raw SQL with `json_each` cross-joins and `GROUP BY`.
- `timeline_entries` uses SQL `strftime` + `GROUP BY` for year/month aggregation.
- `recent_posts` uses SQL `ORDER BY updated_at DESC LIMIT 5`.
- **Posts** (`lib/bds/posts.ex`):
- `dashboard_stats/1` uses `GROUP BY post.status, SELECT {status, count(id)}` instead of loading all statuses.
- **Capabilities** (`lib/bds/scripting/capabilities/`):
- `tag_post_ids/2` uses `json_each` fragment + `SELECT post.id` instead of loading all posts.
- `names_with_counts/2` uses raw SQL with `json_each` + `GROUP BY` instead of loading all posts.
- `posts_by_status/2` filters at SQL level instead of loading all posts and filtering in Elixir.
- Added 20 tests in `test/bds/csm005_sql_filtering_test.exs` covering dashboard stats, tag cloud, sidebar page/post separation, tag/search/year-month filters, available aggregates, and media filtering.
---
## High Severity
### ~~CSM-006 — N+1 Queries in Reindexing & Rendering~~ ✅ FIXED
- **Fixed:** 2026-05-08
- **What was done:**
- **Batch INSERT for reindexing:** Replaced per-row `Repo.query!` INSERT in `reindex_posts/2` and `reindex_media/2` with multi-row batch INSERTs. Rows are chunked at 166 per batch (SQLite 999-parameter limit ÷ 6 columns). Translations were already preloaded in batch; fixed O(n²) `acc ++ [translation]` pattern in `preload_post_translations` and `preload_media_translations` by replacing with `Enum.group_by`.
- **Rendering — preloaded post records:** `PostRendering.post_assigns/2` now accepts an optional `:_post_record` key in assigns, skipping the `Repo.get(Post, id)` re-query when the record is already available.
- **Generation outputs pass records:** `build_page_outputs` and `build_post_outputs` in `outputs.ex` now pass the already-loaded post/translation records via `:_post_record`, eliminating per-post DB queries during generation.
- **ListArchive** already used `load_post_records_batch` (batch query) — no change needed.
- Added telemetry-based query counting tests: reindex 100 posts/media and assert total query count <10.
---
### ~~CSM-007 — Monolithic State Rebuild ("God Function")~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- Decomposed `reload_shell/2` into four focused updaters:
- `refresh_layout/2` — No DB queries. Recomputes workbench-derived assigns (activity_buttons, panel_tabs, current_tab, status_bar, sidebar_header, editor_meta) from existing socket.assigns.
- `refresh_sidebar/2` — Queries sidebar data only, then calls `refresh_layout`.
- `refresh_content/2` — Queries projects, dashboard, git badge, and sidebar data, then calls `refresh_layout`.
- `reload_shell/2` — Full refresh: tab_meta sync, task status, static data, then calls `refresh_content`. Kept for mount, project switch, session restore, and settings changes.
- Replaced all call sites with the minimal refresh needed:
- **Layout-only** (`refresh_layout`): toggle_sidebar, toggle_panel, toggle_assistant_sidebar, select_panel_tab, sync_layout, resize_panel, open_tasks_panel, select_tab, close_tab, toggle_offline_mode, layout menu actions (toggle, close_tab).
- **Sidebar** (`refresh_sidebar`): select_view, all sidebar filter events, sidebar menu actions (view_posts, view_media, edit_preferences, etc.), chat/import editor tab_meta updates.
- **Content** (`refresh_content`): entity_changed (CLI sync), tags_changed, sidebar create/delete.
- **Full reload** (`reload_shell`): mount, activate_project, restore_workbench_session, set_page_language, settings_changed.
- Updated Bridges callbacks to use focused refreshers: `refresh_layout` for toggle events and close_tab, `refresh_sidebar` for view switches and tab meta updates, `refresh_content` for entity/tag changes.
- Split `@local_menu_actions` into `@layout_menu_actions` and `@sidebar_menu_actions` for correct dispatch.
- Fixed `false || true` bug in `refresh_layout` where `offline_mode = assigns[:offline_mode] || true` incorrectly defaulted `false` to `true`.
- Added 7 tests in `test/bds/csm007_reload_shell_test.exs` using telemetry-based query counting: toggle_sidebar (0 queries), toggle_panel (0 queries), sync_layout (0 queries), select_panel_tab (0 queries), toggle_offline_mode (1 query — settings write only), select_view (sidebar queries but no dashboard/projects), sidebar_search (no dashboard queries).
---
### ~~CSM-008 — DB Queries During Render Path~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- **Panel renderer** (`lib/bds/desktop/shell_live/panel_renderer.ex`):
- `render_post_links` and `render_git_log` no longer call DB functions during render. Instead they read from pre-computed assigns (`panel_post_links`, `panel_git_entries`).
- Renamed `post_link_entries/1``fetch_post_link_entries/1` and `git_log_entries/1``fetch_git_log_entries/1`, made them public for use by event handlers.
- **Shell LiveView** (`lib/bds/desktop/shell_live.ex`):
- Added `refresh_panel_data/1` that fetches panel data (post links or git log) based on the active panel tab and stores results in assigns.
- `refresh_layout/2` detects when `current_tab` or `panel.active_tab` changed and calls `refresh_panel_data/1` only when stale — no DB queries on re-renders.
- Initialized `panel_post_links` and `panel_git_entries` assigns in mount.
- **Tab meta** (`lib/bds/desktop/shell_live/tab_helpers.ex`):
- `sync_tab_meta` now skips `derived_tab_meta` DB queries when existing meta already has both title and subtitle populated (`meta_complete?/1` guard).
- Added 5 tests in `test/bds/csm008_render_path_test.exs`: post_links re-render (0 queries), git_log re-render (0 queries), output panel switch (0 queries), tasks panel switch (0 queries), tab meta skip for complete meta (0 queries).
---
### ~~CSM-009 — Thumbnail Generation: Missing Error Handling~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- Replaced all bang variants with non-bang error-tuple handling:
- `Image.autorotate!``Image.autorotate` with `{:ok, {image, rotation_info}}` destructuring.
- `Image.thumbnail!``Image.thumbnail` returning `{:ok, image}` / `{:error, reason}`.
- `Image.embed!``Image.embed` with `with` chain.
- `Image.flatten!``Image.flatten` with `with` chain.
- `Image.write!``Image.write` with `{:ok, _}` / `{:error, reason}` handling.
- `File.mkdir_p` result is now checked — errors halt thumbnail generation with `{:error, reason}`.
- `write_all_thumbnails` uses `Enum.reduce_while` to stop on first error and return `{:error, reason}`.
- `ensure_thumbnails` spec updated to `:ok | {:error, term()}`.
- `regenerate_thumbnails` propagates `{:error, reason}` from `ensure_thumbnails`.
- `regenerate_missing_thumbnails` replaced `try/rescue` with `case` on the new error tuples.
- Call sites in `BDS.Media` (`import_media`, `replace_media_binary`) use `log_thumbnail_error/2` — media operations succeed even if thumbnails fail, with a warning logged.
- Added 6 tests in `test/bds/csm009_thumbnail_error_handling_test.exs`: corrupt image returns `{:error, _}`, non-image returns `:ok`, missing source returns `{:error, _}`, regenerate corrupt returns `{:error, _}`, regenerate_missing counts failures, import succeeds despite thumbnail failure.
---
### ~~CSM-010 — `rescue` for Control Flow in Data Layer~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- Added `BDS.Repo.ready?/0` — a lightweight probe that queries `sqlite_master` (parameterized) to check if core tables exist, without raising exceptions.
- Replaced all 4 `rescue` blocks in `ShellData` (`project_snapshot/0`, `dashboard/1`, `sidebar_view/3`, `git_badge_count/2`) with upfront `Repo.ready?()` checks.
- All four functions now return `{:ok, result}` / `{:error, :not_ready}` tuples instead of silently returning defaults via rescue.
- Updated callers in `ShellLive.refresh_content/2` and `ShellLive.refresh_sidebar/2` to pattern-match the new tuples and fall back to empty defaults only on `{:error, :not_ready}`.
- Made `default_project_snapshot/0` public for use by callers handling the not-ready case.
- Added 10 tests in `test/bds/csm010_rescue_control_flow_test.exs`: `Repo.ready?` returns true when DB is available, each of the 4 functions returns `{:ok, _}` when DB is ready and `{:error, :not_ready}` when the Repo is stopped.
---
## Medium Severity
### ~~CSM-011 — No URL State / Deep Linking~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- `mount/3` now reads `?view=` and `?tab=<type>:<id>` query params and applies them to the initial workbench state, enabling deep linking on page load.
- Added `push_url_state/1` — after state-changing events (`select_view`, `select_tab`, `close_tab`, `open_sidebar_item`, sidebar menu actions, project switch), pushes a `url-state` event to the client with the serialized URL.
- Added JS handler in the `AppShell` hook that calls `history.replaceState` to update the browser URL without triggering navigation.
- URL encoding: `?view=<sidebar_view>` (omitted when `posts`, the default) and `?tab=<type>:<id>` (omitted when no tab is active). Invalid or unknown params are silently ignored.
- Used `push_event` + `history.replaceState` instead of `push_patch`/`handle_params` to maintain compatibility with existing `live_isolated` tests.
- Added 10 tests in `test/bds/csm011_url_state_test.exs`: mount with `?view=media`, mount with default, mount with invalid view, mount with `?tab=post:<id>`, mount with both params, `select_view` pushes url-state, `select_view` posts pushes clean URL, `select_tab` pushes url-state, `close_tab` removes tab from URL, `open_sidebar_item` pushes url-state.
---
### ~~CSM-012 — Desktop File Dialog Blocks Event Handler~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- Replaced synchronous `FilePicker.choose_file/1` call in `SidebarCreate.create/4` for the "media" kind with `Task.async`, storing the task ref in a new `file_picker_task` socket assign.
- Added `handle_file_picker_result/2` private function in `ShellLive` with clauses for `{:ok, _media}`, `:cancel`, `{:error, %{message: _}}`, and `{:error, reason}`.
- Extended the existing `handle_info({ref, result}, socket)` and `handle_info({:DOWN, ref, ...}, socket)` handlers to match on `file_picker_task` ref.
- Added `BDS_DESKTOP_AUTOMATION` guard to `FilePicker.choose_file/1` — returns `:cancel` immediately in automation/test mode, preventing native dialogs from opening during tests.
- Initialized `file_picker_task: nil` assign in mount.
- Added 5 tests in `test/bds/csm012_file_picker_async_test.exs`: event handler returns within 100ms, LiveView handles other events while task is pending, task completion doesn't crash LiveView, cancel is handled gracefully, error results don't crash LiveView.
---
### ~~CSM-013 — Bang Functions in Rendering Pipelines~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- **`lib/bds/rendering/filters.ex`** — `render_macro_template`:
- Replaced `Liquex.parse!` with `Liquex.parse` (non-bang) and `case` match on `{:ok, ast}` / `{:error, reason, line}`.
- Wrapped `Liquex.render!` in `try/rescue` catching `Liquex.Error` specifically (no non-bang `render` exists in Liquex).
- Removed broad `rescue _error -> ""` — errors now log via `Logger.warning` with template path and reason before returning `""`.
- **`lib/bds/rendering/template_selection.ex`** — `render_template`:
- `Liquex.parse` was already non-bang; added `else` clause to normalize the 3-tuple `{:error, reason, line}` into `{:error, "reason at line N"}`.
- Wrapped `Liquex.render!` in `try/rescue` catching `Liquex.Error` specifically, returning `{:error, message}`.
- Removed broad `rescue error -> {:error, error}`.
- **`lib/bds/rendering/post_rendering.ex`** — `post_data_json_value`:
- Replaced `Jason.encode!` with `Jason.encode` and `case` match — returns `"{}"` on encode failure instead of crashing.
- Added 5 tests in `test/bds/csm013_bang_rendering_test.exs`: template syntax error returns `{:error, _}` from `render_template`, broken template in `render_post_page` returns `{:error, _}`, `{% break %}` render error returns `{:error, _}`, normal post context produces valid JSON, non-encodable data returns `"{}"` fallback.
---
### ~~CSM-014 — O(n²) Loops from `length/1` Inside Iteration~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- **`lib/bds/generation/outputs.ex`** — `build_category_outputs`:
- Bound `total_pages = length(paginated_posts)` and `total_items = length(posts)` before the nested loop. Previously called `length/1` 4 times per page × language iteration.
- **`lib/bds/generation/outputs.ex`** — `build_root_outputs`:
- Bound `total_items = length(posts)` before the loop, reused by `pagination_for_page`. Previously called `length(posts)` on every page iteration.
- **`lib/bds/generation/outputs.ex`** — `build_paginated_archive_outputs`:
- Bound `total_items = length(posts)` before the loop. Previously called `length(posts)` inside the nested page × language loop.
- **`lib/bds/rendering/list_archive.ex`** — `build_day_blocks`:
- Bound `last_index = length(grouped_blocks) - 1` before the `Enum.map`. Previously called `length(grouped_blocks)` on every iteration.
- **`lib/bds/publishing.ex`** — `run_upload`:
- Bound `target_count = max(length(targets), 1)` before the `Enum.reduce_while`. Negligible impact (3 targets) but fixed for consistency.
- `lib/bds/ui/sidebar.ex` `acc.draft ++ [post]` was already fixed by CSM-005 (replaced with `Enum.group_by`).
- Added 3 tests in `test/bds/csm014_length_in_loop_test.exs`: multi-page pagination correctness, single-page pagination correctness, 1000-post linear time completion.
---
### ~~CSM-015 — Missing DB Indexes on Foreign Keys~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- Added migration `20260509145208_add_missing_indexes.exs` with indexes for all missing foreign keys and frequently filtered columns:
- FK indexes: `media.project_id`, `post_media.post_id`, `post_media.media_id`, `chat_messages.conversation_id`, `embedding_keys.post_id`, `embedding_keys.project_id`, `dismissed_duplicate_pairs.project_id`, `import_definitions.project_id`, `publish_jobs.project_id`.
- Filter indexes: `posts.status`, `posts.published_at`, `posts.language`.
- Composite index: `db_notifications(entity_type, entity_id)`.
- Added 12 tests in `test/bds/csm015_missing_indexes_test.exs` verifying via `EXPLAIN QUERY PLAN` that all indexed columns use index lookups.
---
### ~~CSM-016 — String Concatenation for Paths~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- **`lib/bds/rendering/file_system.ex`** — Extracted `ensure_liquid_ext/1` using `Path.extname/1` to check before appending `.liquid`, preventing double-extension bugs (e.g. `"header.liquid.liquid"`).
- **`lib/bds/rendering/metadata.ex`** — `menu_item_href` for `:page` kind now applies `URI.encode/1` to the slug (matching the existing `:category_archive` pattern). `href_for_language/1` now uses `String.trim_trailing(prefix, "/")` before appending `/` to prevent double trailing slashes.
- **`lib/bds/rendering/metadata.ex`** — Added `menu_items_from_raw/1` public function for testability.
- **`lib/bds/rendering/links_and_languages.ex`** — `post_path/2` for `nil` language now uses `Path.join(["/", year, month, day, slug]) <> "/"` instead of building with `index.html` then stripping it. Language-prefix clause uses `String.trim_trailing/2` to prevent double slashes. `canonical_media_path_by_source_path/1` uses `Path.join("/", media.file_path)` instead of `"/" <> file_path`.
- **`lib/bds/publishing.ex`** — `ensure_trailing_slash/1` made public for testability (implementation already correct).
- Added 17 tests in `test/bds/csm016_path_concatenation_test.exs`: FileSystem extension handling (bare name, double extension, nested paths), `href_for_language` (empty, with/without trailing slash), menu item href encoding (special chars, plain slugs, category slugs), post_path construction (leading/trailing slashes, no double slashes, language prefix), `language_prefix` (same/nil/different language), `ensure_trailing_slash` (without/with trailing slash, empty string).
---
### ~~CSM-017 — `send(self(), ...)` Component Chatter~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- Created `BDS.Desktop.ShellLive.Notify` — a single dispatch module that standardizes all parent communication from LiveComponent editors. Provides typed functions: `output/3`, `output/4`, `tab_meta/4`, `tab_meta_merge/3`, `close_tab/2`, `reload/0`, `dirty/3`, `command/2`, `open_sidebar_item/2`, and `parent/1` (escape hatch for chat-specific messages).
- Replaced all 25+ `send(self(), ...)` calls across 11 editor components with `Notify.*` calls:
- `post_editor.ex` — 13 calls (dirty, tab_meta, close_tab, output)
- `media_editor.ex` — 7 calls (dirty, tab_meta, output)
- `chat_editor.ex` — 15 calls (output, tab_meta, open_sidebar_item, plus chat-specific via `Notify.parent`)
- `template_editor.ex` — 3 calls (close_tab, output, reload)
- `script_editor.ex` — 3 calls (close_tab, output, reload)
- `misc_editor.ex` — 4 calls (command, output, tab_meta_merge, open_sidebar_item)
- `settings_editor.ex` — 2 calls (output, parent)
- `tags_editor.ex` — 2 calls (output, parent)
- `menu_editor.ex` — 1 call (output)
- `import_editor.ex` — 2 calls (tab_meta, output)
- `overlay_manager.ex` — 3 calls (parent for cross-component routing)
- Consolidated Bridges from 30+ editor-specific `handle_info` clauses to 4 generic handlers: `{:editor_output, ...}`, `{:editor_tab_meta, ...}`, `{:editor_dirty, ...}`, `{:editor_command, ...}`.
- Removed 18 editor-specific message atoms from Bridges (`:post_editor_output`, `:media_editor_output`, `:post_editor_dirty`, `:media_editor_dirty`, `:post_editor_tab_meta`, etc.).
- Kept chat-specific messages (`{:chat_editor_task_started, ...}`, `{:chat_editor_toggle_sidebar}`, etc.) and cross-component routing (`{:post_editor_insert_content, ...}`) in Bridges since they originate from AI streaming or overlay actions, not from editor self-notification.
- Added 24 tests in `test/bds/csm017_component_chatter_test.exs`: 11 source-level tests asserting no `send(self(), ...)` in any editor file, 1 aggregate test verifying all shell_live `send(self(), ...)` calls are in `notify.ex`, 2 Bridges tests verifying old patterns are gone and new generic handlers exist, 10 Notify API tests verifying each function sends the correct message.
---
## Low Severity / Code Quality
### ~~CSM-018 — `@moduledoc false` Epidemic~~ ✅ FIXED
- **Fixed:** 2026-05-10
- **What was done:**
- Replaced `@moduledoc false` with descriptive `@moduledoc` strings in all 12 listed public modules:
- `lib/bds/i18n.ex` — language support, locale resolution, flag emoji mapping
- `lib/bds/map_utils.ex` — mixed-key map utilities and safe atom conversion
- `lib/bds/bounded_atoms.ex` — allow-list-based dynamic atom conversion
- `lib/bds/document_fields.ex` — frontmatter field access with key aliases
- `lib/bds/import_definitions.ex` — CRUD for WXR import configurations
- `lib/bds/publishing.ex` — GenServer for site upload job coordination
- `lib/bds/settings.ex` — global key-value settings persistence
- `lib/bds/templates.ex` — Liquid template lifecycle management
- `lib/bds/ai.ex` — AI endpoint config, secrets, and inference dispatch
- `lib/bds/mcp.ex` — MCP server facade for external AI agents
- `lib/bds/scripting/capabilities.ex` — Lua scripting capability map builder
- `lib/bds/scripting/api_docs.ex` — machine-readable Lua API documentation
---
### ~~CSM-019 — Missing `@spec` on Public Functions~~ ✅ FIXED
- **Fixed:** 2026-05-10
- **What was done:**
- Added `@spec` annotations to every public function across 25 files in rendering, generation, publishing, UI, and scripting modules.
- Added `@type t :: %__MODULE__{}` to `workbench.ex` and `file_system.ex` to support struct-based specs.
- Rendering: `post_rendering.ex`, `links_and_languages.ex`, `labels.ex`, `metadata.ex`, `file_system.ex`, `filters.ex`, `list_archive.ex`, `template_selection.ex`
- Generation: `generated_file_hash.ex`
- Publishing: `publishing.ex`
- UI: `registry.ex`, `session.ex`, `sidebar.ex`, `menu_bar.ex`, `commands.ex`, `dashboard.ex`, `workbench.ex`
- Scripting: `job_store.ex`, `job_runner.ex`, `job_supervisor.ex`, `capabilities.ex`, `capabilities/util.ex`, `api_docs.ex`
- Dialyzer passes with 0 errors; all 619 tests pass.
---
### ~~CSM-020 — Deeply Nested `case` Instead of `with`~~ ✅ FIXED
- **Fixed:** 2026-05-10
- **What was done:**
- **`lib/bds/import_definitions.ex`** — `delete_definition/1`: Replaced nested `case` piped into another `case` with a flat `with` chain: `Repo.get``Repo.delete``{:ok, :deleted}`, with `else` clauses for `nil` and `{:error, _}`.
- **`lib/bds/publishing.ex`** — `handle_call({:update_job, ...})`: Replaced `case Repo.get` with `with %PublishJob{} = job <- Repo.get(...)`. Also replaced `Repo.update!()` with `Repo.update()` to avoid crashes on changeset errors.
- **`lib/bds/templates.ex`** — `update_template/2`: Replaced outer `case Repo.get` with `with` + extracted `do_update_template/2` private function. Collapsed three levels of nested `case` (Repo.get → transaction_result → sync_side_effects) into a single flat `with` chain.
- Added 7 tests in `test/bds/csm020_nested_case_test.exs`: delete_definition success and not-found, update_template success and not-found, source-level assertions that all three files use `with` instead of nested `case`.
---
### ~~CSM-021 — `cond` Where Pattern Matching Suffices~~ ✅ FIXED
- **Fixed:** 2026-05-11
- **What was done:**
- **`lib/bds/ai.ex`** — `get_endpoint/2`: Replaced `cond do is_nil(x) and ...; true -> ... end` with a simple `if/else` since there are only two branches.
- **`lib/bds/scripting/api_docs.ex`** — `example_response_value/1`: Extracted `"nil"` literal match into a separate function head. Replaced remaining `cond` with `case` on a tuple of guard results.
- **`lib/bds/scripting/api_docs.ex`** — `example_field_value/1`: Replaced `cond` with `case` on a tuple of `String.contains?`/`String.ends_with?` results.
- Added 2 source-level tests in `test/bds/csm021_cond_pattern_match_test.exs` asserting no `cond do` blocks remain in either file.
---
### ~~CSM-022 — Silent Error Swallowing~~ ✅ FIXED
- **Fixed:** 2026-05-11
- **What was done:**
- `execute_macro/4` now returns `{:error, reason}` instead of `{:ok, ""}` when the underlying script execution fails.
- Added `Logger.warning/1` call that logs the project ID and error reason before returning the error tuple.
- Updated test in `api_test.exs` to assert `{:error, _reason}` instead of `{:ok, ""}` for failing macros.
---
### ~~CSM-023 — SRP Violations~~ ✅ FIXED
- **Fixed:** 2026-05-11
- **What was done:**
- **`lib/bds/templates.ex`** — `do_update_template/2`:
- Extracted `resolve_next_slug/2` — determines slug from attrs or keeps current.
- Extracted `content_changed?/2` — checks if content attr differs from effective content.
- Extracted `resolve_next_status/2` — pattern-matched function heads for status transition (published + content change → draft).
- Extracted `build_update_attrs/5` — assembles the changeset map from resolved values.
- Extracted `commit_update_transaction/4` — runs the Repo transaction with cascade logic.
- `do_update_template/2` is now a concise pipeline: resolve → build → commit → sync.
- **`lib/bds/scripting/capabilities.ex`** — `for_project/2`:
- Extracted 13 domain-specific builder functions: `app_capabilities/2`, `project_capabilities/1`, `meta_capabilities/1`, `post_capabilities/1`, `media_capabilities/1`, `script_capabilities/1`, `template_capabilities/1`, `tag_capabilities/1`, `task_capabilities/0`, `sync_capabilities/2`, `publish_capabilities/2`, `chat_capabilities/1`, `embedding_capabilities/1`.
- `for_project/2` is now a 15-line dispatch map.
- Added 5 tests in `test/bds/csm023_srp_violations_test.exs`: source-level assertions for helper extraction in templates, delegation in do_update_template, builder function presence in capabilities, concise for_project body (≤20 lines), no inline capability definitions in for_project.
---
### ~~CSM-024 — `Enum.reduce` with `acc.draft ++ [post]` (O(n²))~~ ✅ FIXED
- **Fixed:** 2026-05-08 (as part of CSM-005)
- **What was done:** Replaced `acc.draft ++ [post]` with `Enum.group_by/2` in `group_posts/1`. See CSM-005 entry for details.
---
### ~~CSM-025 — Hardcoded Language Prefixes~~ ✅ FIXED
- **Fixed:** 2026-05-11
- **What was done:**
- Replaced hardcoded `["de/", "fr/", "it/", "es/"]` in `language_match?/2` with dynamically derived prefixes from `plan.blog_languages` and `plan.language`.
- `build_outputs/2` now computes `other_prefixes` by rejecting the main language from `blog_languages` and appending `"/"` to each.
- `pages_for_language/3` and `language_match?/3` now accept the computed prefixes as a parameter instead of using a hardcoded list.
- Works correctly with arbitrary language codes (e.g. `pt-br`, `zh-cn`, `ja`) that were not in the old hardcoded list.
- Added 5 tests in `test/bds/csm025_hardcoded_languages_test.exs`: source-level assertion for no hardcoded prefixes, main language exclusion, non-main language inclusion, arbitrary language codes, single-language blog.
---
### ~~CSM-026 — TOCTOU Race Condition in Template File System~~ ✅ FIXED
- **Fixed:** 2026-05-11
- **What was done:**
- Extracted `candidate_paths/2` — validates the template path and returns all candidate file paths without checking existence.
- Added `try_read/2` — attempts `File.read` on each candidate path sequentially, returning `{:ok, contents}` on first success or `{:error, :enoent}` when all fail. No separate existence check.
- Simplified `full_path/2` to delegate to `candidate_paths/2` (returns first candidate for backward compatibility with tests).
- Rewrote `Liquex.FileSystem` protocol impl to use `try_read/2` directly, eliminating the TOCTOU window between `File.regular?` and `File.read`.
- Added 10 tests in `test/bds/csm026_toctou_file_system_test.exs`: atomic read, missing template, multi-root fallthrough, first-root-wins priority, file-deleted-between-calls safety, protocol read, protocol raise on missing, and path validation (empty, absolute, traversal).
---
### CSM-027 — `if result == :ok` Instead of Pattern Matching
- **File:** `lib/bds/templates.ex:445`
- **Fix:** Use `case result do :ok -> ...; _ -> ... end`.
---
### CSM-028 — Broad `rescue` Swallowing Template Errors
- **File:** `lib/bds/rendering/filters.ex:130-132`
- **What:** `rescue _error -> ""` swallows all macro template failures silently.
- **Fix:** Rescue only specific exceptions, or return `{:error, exception}` and let the caller decide.
---
### CSM-029 — `length/1` in Guards or Comparisons
- **Files:** `lib/bds/generation/outputs.ex`, `lib/bds/ui/sidebar.ex`
- **What:** `length(list)` is O(n). Using it inside a loop makes the whole loop O(n²).
- **Fix:** Bind the length before the loop.
---
### CSM-030 — Unchecked `File.mkdir_p` / `File.mkdir_p!`
- **Files:** `lib/bds/media/thumbnails.ex:133`, `lib/bds/media/sidecars.ex:24,56`, `lib/bds/release_packaging.ex:80,85`
- **What:** Result of `File.mkdir_p/1` is discarded. `File.mkdir_p!/1` in `release_packaging` can crash on permission errors.
- **Fix:** Pattern-match `File.mkdir_p/1` or use `with`; replace bang variants with non-bang and handle errors.
---
### CSM-031 — `try/rescue` Instead of `with` and Error Tuples
- **Files:** `lib/bds/rendering/filters.ex`, `lib/bds/rendering/template_selection.ex`, `lib/bds/desktop/shell_data.ex`
- **Fix:** Replace `try/rescue` around expected failures with non-bang functions and `with` chains.
---
### CSM-032 — `Map.get` with Default Instead of Pattern Matching
- **Files:** Widespread
- **What:** `Map.get(map, key, default)` when the key is expected to exist.
- **Fix:** Use pattern matching (`%{key: value} = map`) or `Map.fetch!/2` if the key is required.
---
### CSM-033 — `Enum.each` with Side Effects That Should Be Batch Inserts
- **Files:** `lib/bds/search.ex:174-177`, `lib/bds/embeddings.ex`
- **What:** `Enum.each` used for inserting records. The side-effect pattern is fine, but `Enum.map` + `Repo.insert_all` would be much faster for bulk inserts.
- **Fix:** Use `Repo.insert_all` for batch inserts instead of `Enum.each` + `Repo.insert`.
---
### CSM-034 — `File.read!` / `File.write!` Without Error Handling
- **Files:** `lib/bds/preview_assets.ex:32`, `lib/bds/release_packaging.ex:105`, `lib/bds/templates.ex:488-489`
- **Fix:** Use `File.read/1`, `File.write/2`, and handle `{:error, reason}`.
---
### CSM-035 — Process Dictionary (`Process.get/put`) Usage
- **File:** `lib/bds/desktop/ui_locale.ex:32,49,65`
- **What:** `UILocale.put/1` sets process dictionary (`Process.put(@key, locale)`) for UI locale. Used in `ShellLive.render` (Z. 550) and `MenuBar`.
- **Fix:** This is isolated to the LiveView/MenuBar process so it's low-risk, but document the invariant explicitly: the process dict key `:bds_ui_locale` is set before each render call.
---
### CSM-036 — Missing `@impl true` on GenServer Callbacks
- **File:** `lib/bds/publishing.ex:46,61,71,75`
- **What:** Only `init/1` (Z. 36) and the first `handle_call` (Z. 41) have `@impl true`. The remaining `handle_call` clauses at Z. 46, 61, 71, 75 lack it.
- **Fix:** Add `@impl true` before every `handle_call`, `handle_cast`, `handle_info`, and `terminate`.
---
## Checklist for Agents Picking Up This File
- [x] All critical items (CSM-001 to CSM-005) have been addressed or explicitly deferred with justification.
- CSM-001: Fixed. All `String.to_atom` on dynamic data replaced with `MapUtils.safe_atomize_key/keys` or `String.to_existing_atom`.
- CSM-002: Fixed. Search now pushes all filtering and pagination into SQL via Ecto queries and CTEs.
- CSM-004: Fixed. `attach_runner` moved to `handle_continue`, `terminate/2` added for cleanup, `restart: :temporary` set, JobStore `detach_runner` bug fixed.
- [x] All high-severity items (CSM-006 to CSM-010) have been addressed.
- CSM-006: Fixed. Batch INSERT for reindexing, preloaded post records for rendering.
- CSM-007: Fixed. Decomposed into refresh_layout, refresh_sidebar, refresh_content, reload_shell.
- CSM-008: Fixed. Panel data pre-computed in event handlers, tab meta skips DB for complete entries.
- CSM-009: Fixed. All bang Image/File variants replaced with error-tuple handling, `ensure_thumbnails` returns `{:error, _}` instead of crashing.
- CSM-010: Fixed. Replaced rescue blocks with `Repo.ready?/0` probe and `{:ok, _}`/`{:error, :not_ready}` tuples.
- [x] CSM-001 fix covers ALL 6 affected files, not just `import_definitions.ex`.
- [x] CSM-003 fix covers ALL `Repo.delete!` call sites (posts, tags, scripts, media, projects, templates, translations).
- [x] CSM-007 decomposition is the prerequisite for fixing CSM-008 (render-path queries).
- [x] Tests were written **before** implementation changes (Red → Green → Refactor).
- [x] Full test suite passes: `mix test`.
- [x] Dialyzer passes cleanly: `mix dialyzer` (zero warnings).
- [x] Build succeeds: `mix compile`.
- [x] No external JS/CSS referenced in preview/generated HTML (per AGENTS.md).
- [x] All UI strings use gettext / i18n, no hardcoded text.
- [x] API docs (`API.md`) updated if any API changes were made.
- [x] Metadata diff tool and rebuild-from-database updated if metadata changed.
- [x] Specs in `specs/` folder updated and validated if behavior changed.
- [x] Unused code (including tests for removed features) has been deleted.
- [x] This `CODESMELL.md` updated: fixed items removed, new ones added.

View File

@@ -10,45 +10,40 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| ID | Gap | Spec | Code | Path |
|---|---|---|---|---|
| A1-1 | No `archived→draft` or `archived→published` transition | post.allium:121-122 | No code path to unarchive | Fix code or spec-restrict transitions |
| A1-1 | No `archived→draft` or `archived→published` transition | post.allium:121-122 | No code path to unarchive | Fix code: implement unarchive transitions |
| A1-2 | `DeletePost` must delete translations + translation files | post.allium:209-212 | `delete_post/1` skips translation cleanup | Fix code: delete PostTranslation rows + files |
| A1-3 | Publish must delete old file when path changes | engine_side_effects.allium:73-74 | `publish_post` does not delete old file | Fix code: add old file deletion on path change |
| A1-4 | `doNotTranslate: false` written to frontmatter despite "only when true" | frontmatter.allium:398 | `lib/bds/frontmatter.ex:38-39` writes false | Fix code: omit `doNotTranslate` when false |
| A1-5 | Auto-save after 3000ms idle | editor_post.allium:183-188 | No auto-save timer | Fix code: implement auto-save on idle + unmount + tab switch |
| A1-6 | On-demand rendering in preview server | preview.allium:53-93 | Server serves static pre-generated files | Fix code: implement on-demand template rendering for post/archive/language routes |
| A1-7 | Template lookup must use all 4 levels (post→tag→category→default) | template_context.allium:267-277 | Only levels 1 and 4 implemented; tag/category fallback unused | Fix code: implement levels 2-3 in template_selection.ex |
| A1-8 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Fix code: add validation step before publish |
| A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Fix code: implement preset color palette popover |
| A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create |
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown |
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
### A2. Spec Should Update (code is normative)
| ID | Gap | Spec | Code | Path |
|---|---|---|---|---|
| A2-1 | WYSIWYG/visual editor mode (3 modes) | editor_post.allium:159-164 | Only markdown+preview; visual normalizes to markdown | Drop from spec or mark future |
| A2-2 | Auto-save after 3000ms idle | editor_post.allium:183-188 | No auto-save timer | Drop from spec or mark future |
| A2-3 | On-demand rendering in preview | preview.allium:53-93 | Static file serving from generated output | Update spec: preview serves pre-generated files |
| A2-4 | Template/Script are global entities | template.allium, script.allium | Both have `project_id`, per-project uniqueness | Update spec to per-project scoping |
| A2-5 | TagsFile uses `{tags: [...]}` wrapper | frontmatter.allium:255-273 | Code writes bare array `[...]` | Update spec |
| A2-6 | Sidecar is "YAML-like, not gray-matter" | frontmatter.allium:174 | Code wraps with `---` delimiters | Update spec to gray-matter style |
| A2-7 | Translation frontmatter omits status/timestamps | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | Update spec to match written fields |
| A2-8 | Search index has single `stemmed_content` | search.allium:40-54 | FTS5 per-field stemmed columns | Update spec to per-field model |
| A2-9 | Tag archives are single-page | generation.allium:142-147 | Code paginates | Update spec |
| A2-10 | Date archives year+month only | generation.allium:151-159 | Code also generates day-level | Update spec |
| A2-11 | Menu is DB entity | menu.allium:20-26 | Purely file-based OPML, no DB table | Update spec to file-only model |
| A2-12 | Panel tabs: problems, terminal | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | Update spec |
| A2-13 | Template lookup 4 levels (post→tag→category→default) | template_context.allium:267-277 | Only levels 1 and 4 implemented | Drop levels 2-3 or implement |
| A2-14 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Add to code or drop from spec |
| A2-15 | Graceful shutdown with inflight tracking | preview.allium:47-48 | Kills acceptor, no inflight tracking | Drop from spec |
| A2-16 | Pagefind as real library | generation.allium:208 | Simplified JSON-based mock | Update spec to mock model |
| A2-17 | 24 Snowball stemmers all with algorithms | search.allium:26-31 | Only 15/24 have algorithms; 9 pass through unstemmed | Update spec: 15 stemmed + 9 passthrough |
| A2-18 | Git sidebar: commit input, history, push/pull | sidebar_views.allium | Only "Working tree" item | Mark as partial/TODO in spec |
| A2-19 | 17 preset colors in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Update spec |
| A2-20 | Slug timestamp fallback after 999 | post.allium:21 | Unbounded numeric suffix | Update spec or fix code |
| A2-21 | Thumbnail generation is async | engine_side_effects.allium:117 | Synchronous | Update spec or fix code |
### A3. Decisions Needed
| ID | Gap | Spec | Code | Path |
|---|---|---|---|---|
| A3-1 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Decide: write file on create, or update spec |
| A3-2 | `provider_package_ref` on AiModel | schema.allium:282 | Not in code | Decide: add field or drop from spec |
| A3-3 | AiModelModality: :video vs :file/:tool | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | Decide: which modalities are correct |
| A3-4 | JSON key convention: snake_case vs camelCase | frontmatter.allium values | Code uses camelCase for all metadata JSON | Decide normative convention |
| A2-2 | Template/Script are global entities | template.allium, script.allium | Both have `project_id`, per-project uniqueness | Update spec to per-project scoping |
| A2-3 | TagsFile uses `{tags: [...]}` wrapper | frontmatter.allium:255-273 | Code writes bare array `[...]` | Update spec |
| A2-4 | Sidecar is "YAML-like, not gray-matter" | frontmatter.allium:174 | Code wraps with `---` delimiters | Update spec to gray-matter style |
| A2-5 | Translation frontmatter omits status/timestamps | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | Update spec to match written fields |
| A2-6 | Search index has single `stemmed_content` | search.allium:40-54 | FTS5 per-field stemmed columns | Update spec to per-field model |
| A2-7 | Tag archives are single-page | generation.allium:142-147 | Code paginates | Update spec |
| A2-8 | Date archives year+month only | generation.allium:151-159 | Code also generates day-level | Update spec |
| A2-9 | Menu is DB entity | menu.allium:20-26 | Purely file-based OPML, no DB table | Update spec to file-only model |
| A2-10 | Panel tabs: problems, terminal | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | Update spec |
| A2-11 | Git sidebar: commit input, history, push/pull | sidebar_views.allium | Only "Working tree" item | Mark as partial/TODO in spec |
| A2-12 | Slug timestamp fallback after 999 | post.allium:21 | Unbounded numeric suffix | Update spec or fix code |
| A2-13 | Thumbnail generation is async | engine_side_effects.allium:117 | Synchronous | Update spec or fix code |
| A2-14 | AiModelModality: :video vs :file/:tool | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | Update spec to :file/:tool |
| A2-15 | JSON key convention: snake_case vs camelCase | frontmatter.allium values | Code uses camelCase for all metadata JSON | Update spec to camelCase |
| A2-16 | Snowball stemmer language list | search.allium:26-31 | Library determines which have algorithms vs passthrough | Update spec: don't enumerate; just say "Snowball stemmers via library" |
| A2-17 | `provider_package_ref` on AiModel | schema.allium:282 | Not in code; legacy field not needed | Drop from spec |
---
@@ -97,11 +92,13 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
## C. Internal Spec Inconsistencies
| ID | Conflict | Location | Path |
All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Conflict | Resolution | Path |
|---|---|---|---|
| C-1 | schema.allium ChatMessage has no cache tokens; ai.allium ChatMessage has `cache_read_tokens`/`cache_write_tokens` | schema.allium:235-243 vs ai.allium:147-156 | Align schema.allium with ai.allium (code matches ai.allium) |
| C-2 | media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it | media.allium:28 vs frontmatter.allium:171-190 | Add `linkedPostIds` to frontmatter.allium |
| C-3 | translation.allium says status/timestamps omitted from translation files; frontmatter.allium TranslationFrontmatter defines only 5 fields; code writes 8+ fields | translation.allium:67-74, frontmatter.allium:107-117 | Reconcile: either update spec or fix code |
| C-1 | schema.allium ChatMessage has no cache tokens; ai.allium ChatMessage has `cache_read_tokens`/`cache_write_tokens` | Code has cache tokens → align schema.allium with ai.allium | Update schema.allium |
| C-2 | media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it | Code writes `linkedPostIds` → add to frontmatter.allium | Update frontmatter.allium |
| C-3 | translation.allium says status/timestamps omitted; frontmatter.allium TranslationFrontmatter defines only 5 fields; code writes 8+ fields | Code writes status/timestamps → update both specs to match code | Update translation.allium + frontmatter.allium |
---
@@ -184,13 +181,12 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
## Priority Order for Resolution
1. **A1-1 through A1-4** — code bugs (spec is correct)
1. **A1-1 through A1-12** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown)
2. **D1-1 through D1-18** — untested invariants/guarantees
3. **C-1 through C-3** — internal spec inconsistencies
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
4. **B1-1 through B1-6** — major code behaviors missing from spec
5. **A2-1 through A2-21** — spec drift (code is normative)
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
6. **D2-1 through D2-17** — untested rules
7. **D3-1 through D3-11** — partial test coverage
8. **B1-7 through B1-20** — minor code behaviors missing from spec
9. **D4-1 through D4-7** — UI test coverage
10. **A3-1 through A3-4** — decisions needed

View File

@@ -186,4 +186,12 @@ defmodule BDS.AI do
@spec cancel_chat(String.t()) :: :ok
defdelegate cancel_chat(conversation_id), to: Chat
@spec get_surface_state(String.t()) :: map()
defdelegate get_surface_state(conversation_id), to: Chat
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
{:ok, map()} | {:error, term()}
defdelegate put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces),
to: Chat
end

View File

@@ -62,6 +62,42 @@ defmodule BDS.AI.Chat do
Repo.get(ChatConversation, conversation_id)
end
@spec get_surface_state(String.t()) :: map()
def get_surface_state(conversation_id) when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do
%ChatConversation{surface_state: state} when is_map(state) -> state
_other -> %{}
end
end
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
{:ok, map()} | {:error, term()}
def put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces)
when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do
nil ->
{:error, :not_found}
%ChatConversation{} = conversation ->
state = %{
"surface_data" => surface_data,
"surface_tabs" => surface_tabs,
"dismissed_surfaces" => MapSet.to_list(dismissed_surfaces)
}
conversation
|> ChatConversation.changeset(%{
surface_state: state,
updated_at: Persistence.now_ms()
})
|> Repo.update()
|> case do
{:ok, _updated} -> {:ok, state}
error -> error
end
end
end
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do

View File

@@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do
title: String.t() | nil,
model: String.t() | nil,
copilot_session_id: String.t() | nil,
surface_state: map() | nil,
created_at: integer() | nil,
updated_at: integer() | nil
}
@@ -19,13 +20,14 @@ defmodule BDS.AI.ChatConversation do
field :title, :string
field :model, :string
field :copilot_session_id, :string
field :surface_state, :map
field :created_at, :integer
field :updated_at, :integer
end
def changeset(conversation, attrs) do
conversation
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at],
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
empty_values: [nil]
)
|> validate_required([:id, :title, :created_at, :updated_at])

View File

@@ -48,29 +48,32 @@ defmodule BDS.Desktop.Overlay do
end
def open(:media, :confirm_delete, context) do
delete_details = Map.get(context, :delete_details, %{})
%{
title: title,
entity_name: entity_name,
entity_type: entity_type,
reference_list: reference_list
} = context.delete_details
%{
kind: :confirm_delete,
title: Map.get(delete_details, :title, "Delete"),
entity_name: Map.get(delete_details, :entity_name, ""),
entity_type: Map.get(delete_details, :entity_type, "media"),
reference_count: length(Map.get(delete_details, :reference_list, [])),
reference_list: Map.get(delete_details, :reference_list, [])
title: title,
entity_name: entity_name,
entity_type: entity_type,
reference_count: length(reference_list),
reference_list: reference_list
}
end
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
def open(:tags, :confirm_merge, context) do
merge = Map.get(context, :merge_details, %{})
target = Map.get(merge, :target, "")
count = Map.get(merge, :count, 0)
%{title: title, message: message} = context.merge_details
%{
kind: :confirm_dialog,
title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"),
message: Map.get(merge, :message, "Cannot be undone.")
title: title,
message: message
}
end
@@ -115,8 +118,8 @@ defmodule BDS.Desktop.Overlay do
|> Map.get(:all_media, [])
|> Enum.filter(fn media ->
normalized == "" or
search_matches?(Map.get(media, :title, ""), normalized) or
search_matches?(Map.get(media, :original_name, ""), normalized)
search_matches?(media.title, normalized) or
search_matches?(media.original_name, normalized)
end)
|> Enum.map(&to_insert_media_result/1)
@@ -203,18 +206,22 @@ defmodule BDS.Desktop.Overlay do
def insert_media_result(_overlay, _media_id), do: nil
defp language_picker(context, source_language) do
existing_translations = Map.get(context, :existing_translations, %{})
language_names = Map.get(context, :language_names, %{})
language_flags = Map.get(context, :language_flags, %{})
targets =
context
|> Map.get(:blog_languages, [])
|> Enum.uniq()
|> Enum.reject(&(&1 == source_language))
|> Enum.map(fn code ->
existing_status = Map.get(Map.get(context, :existing_translations, %{}), code)
existing_status = Map.get(existing_translations, code)
%{
code: code,
name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)),
flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code),
name: Map.get(language_names, code, String.upcase(code)),
flag_emoji: Map.get(language_flags, code, code),
has_existing_translation: not is_nil(existing_status),
existing_status: existing_status
}
@@ -255,14 +262,15 @@ defmodule BDS.Desktop.Overlay do
def set_ai_suggestions_error(overlay, _error_message), do: overlay
defp normalize_ai_fields(fields) do
Enum.map(fields, fn field ->
Enum.map(fields, fn %{key: key, label: label, current_value: current,
suggested_value: suggested, locked: locked} = field ->
%{
key: to_string(Map.get(field, :key, "")),
label: Map.get(field, :label, ""),
current_value: Map.get(field, :current_value, ""),
suggested_value: Map.get(field, :suggested_value, ""),
accepted: not Map.get(field, :locked, false),
locked: Map.get(field, :locked, false),
key: to_string(key),
label: label,
current_value: current,
suggested_value: suggested,
accepted: not locked,
locked: locked,
loading: Map.get(field, :loading, false)
}
end)
@@ -276,7 +284,7 @@ defmodule BDS.Desktop.Overlay do
end
defp gallery_images(context) do
images = Enum.filter(Map.get(context, :media, []), &Map.get(&1, :is_image, false))
images = Enum.filter(Map.get(context, :media, []), & &1.is_image)
post_media_ids = Map.get(context, :post_media_ids, [])
case Enum.filter(images, &(&1.id in post_media_ids)) do
@@ -289,29 +297,29 @@ defmodule BDS.Desktop.Overlay do
%{
post_id: post.id,
title: post.title,
status: to_string(Map.get(post, :status, "draft")),
canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"),
similarity_score: Map.get(post, :similarity_score)
status: post.status,
canonical_url: post.canonical_url,
similarity_score: nil
}
end
defp to_insert_media_result(media) do
%{
media_id: media.id,
title: Map.get(media, :title, ""),
original_name: Map.get(media, :original_name, media.id),
is_image: Map.get(media, :is_image, false),
thumbnail_url: Map.get(media, :thumbnail_url)
title: media.title,
original_name: media.original_name,
is_image: media.is_image,
thumbnail_url: media.thumbnail_url
}
end
defp to_gallery_image(media) do
%{
media_id: media.id,
thumbnail_url: Map.get(media, :thumbnail_url),
image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)),
alt_text: Map.get(media, :alt_text),
title: Map.get(media, :title, Map.get(media, :original_name, media.id))
thumbnail_url: media.thumbnail_url,
image_url: media.image_url,
alt_text: media.alt_text,
title: media.title
}
end

View File

@@ -449,7 +449,7 @@ defmodule BDS.Desktop.ShellCommands do
end
defp translation_fill_enabled?(metadata) do
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, []))
([metadata.main_language] ++ metadata.blog_languages)
|> Enum.map(fn language ->
language
|> to_string()

View File

@@ -116,6 +116,15 @@ defmodule BDS.Desktop.ShellLive.Bridges do
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
end
def handle_info({:persist_surface_state, conversation_id}, socket, _callbacks) do
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :persist_surface_state
)
{:noreply, socket}
end
def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
{:noreply,
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}

View File

@@ -1,6 +1,8 @@
defmodule BDS.Desktop.ShellLive.ChatEditor do
@moduledoc false
require Logger
use Phoenix.LiveComponent
import Phoenix.HTML, only: [raw: 1]
@@ -37,6 +39,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
{:ok, do_note_streaming_content(socket, content)}
end
def update(%{action: :persist_surface_state}, socket) do
{:ok, persist_surface_state(socket)}
end
def update(assigns, socket) do
socket =
socket
@@ -97,7 +103,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
socket
) do
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
{:noreply, assign(socket, :surface_data, next_data) |> build_data()}
{:noreply, assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()}
end
def handle_event(
@@ -111,6 +117,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
:surface_tabs,
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
)
|> persist_surface_state()
|> build_data()
{:noreply, socket}
@@ -120,6 +127,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
socket =
socket
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|> persist_surface_state()
|> build_data()
{:noreply, socket}
@@ -148,14 +156,29 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp ensure_state(socket) do
conversation_id = socket.assigns.current_tab.id
persisted = AI.get_surface_state(conversation_id)
{surface_data, surface_tabs, dismissed_surfaces} =
case persisted do
state when is_map(state) and map_size(state) > 0 ->
{
state["surface_data"] || %{},
state["surface_tabs"] || %{},
MapSet.new(state["dismissed_surfaces"] || [])
}
_other ->
{%{}, %{}, MapSet.new()}
end
defaults = %{
conversation_id: conversation_id,
input: "",
model_selector_open?: false,
request: nil,
surface_data: %{},
surface_tabs: %{},
dismissed_surfaces: MapSet.new(),
surface_data: surface_data,
surface_tabs: surface_tabs,
dismissed_surfaces: dismissed_surfaces,
action_error: nil
}
@@ -819,6 +842,41 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Private helpers ───────────────────────────────────────────────────────
@surface_state_debounce_ms 500
defp persist_surface_state(socket) do
conversation_id = socket.assigns.conversation_id
surface_data = socket.assigns.surface_data
surface_tabs = socket.assigns.surface_tabs
dismissed_surfaces = socket.assigns.dismissed_surfaces
case AI.put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces) do
{:ok, _state} ->
:ok
{:error, reason} ->
Logger.warning("Failed to persist surface state for conversation #{conversation_id}",
reason: inspect(reason)
)
end
socket
end
defp schedule_surface_state_persist(socket) do
if socket.assigns[:surface_state_timer] do
Process.cancel_timer(socket.assigns[:surface_state_timer])
end
timer =
Process.send_after(
self(),
{:persist_surface_state, socket.assigns.conversation_id},
@surface_state_debounce_ms
)
assign(socket, :surface_state_timer, timer)
end
defp active_project_id(socket) do
socket.assigns[:project_id]

View File

@@ -266,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@spec default_author(term()) :: term()
def default_author(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
Map.get(metadata, :default_author)
metadata.default_author
end
@spec suggested_definition_name(term()) :: term()

View File

@@ -222,7 +222,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
%{
categories: Enum.uniq(Map.get(metadata, :categories, []) || []),
categories: Enum.uniq(metadata.categories || []),
tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq()
}
end

View File

@@ -22,8 +22,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
@spec category_rows(term()) :: term()
def category_rows(metadata) do
categories = Map.get(metadata, :categories, [])
settings = Map.get(metadata, :category_settings, %{})
categories = metadata.categories
settings = metadata.category_settings
Enum.map(categories, fn category ->
category_settings = Map.get(settings, category, %{})
@@ -167,7 +167,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end
end
defp category_names(metadata), do: Map.get(metadata, :categories, [])
defp category_names(metadata), do: metadata.categories
defp ensure_default_categories(project_id) do
Enum.reduce_while(Map.keys(@default_category_settings), :ok, fn category, _acc ->

View File

@@ -16,17 +16,17 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
@spec project_form(term()) :: term()
def project_form(metadata) do
%{
"name" => Map.get(metadata, :name, ""),
"description" => Map.get(metadata, :description, ""),
"public_url" => Map.get(metadata, :public_url, ""),
"main_language" => Map.get(metadata, :main_language) || "en",
"default_author" => Map.get(metadata, :default_author, ""),
"max_posts_per_page" => Integer.to_string(Map.get(metadata, :max_posts_per_page, 50)),
"name" => metadata.name || "",
"description" => metadata.description || "",
"public_url" => metadata.public_url || "",
"main_language" => metadata.main_language || "en",
"default_author" => metadata.default_author || "",
"max_posts_per_page" => Integer.to_string(metadata.max_posts_per_page),
"blogmark_category" =>
Map.get(metadata, :blogmark_category) ||
List.first(Map.get(metadata, :categories, [])) || "article",
"blog_languages" => Map.get(metadata, :blog_languages, []),
"semantic_similarity_enabled" => Map.get(metadata, :semantic_similarity_enabled, false)
metadata.blogmark_category ||
List.first(metadata.categories) || "article",
"blog_languages" => metadata.blog_languages,
"semantic_similarity_enabled" => metadata.semantic_similarity_enabled
}
end

View File

@@ -8,7 +8,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
@spec publishing_form(term()) :: term()
def publishing_form(metadata) do
prefs = Map.get(metadata, :publishing_preferences, %{})
prefs = metadata.publishing_preferences
%{
"ssh_host" => Map.get(prefs, "ssh_host", ""),

View File

@@ -88,7 +88,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
def current_theme(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} ->
case Map.get(metadata, :pico_theme) do
case metadata.pico_theme do
nil -> "default"
"" -> "default"
theme -> theme

View File

@@ -11,6 +11,21 @@ defmodule BDS.Desktop.UILocale do
process dictionary directly. Use `with_locale/2` around any render or
component that needs a locale binding; use `current/0` to read it.
## Invariant
Every code path that evaluates HEEx templates containing `translated/1,2`
calls **must** call `UILocale.put/1` before template evaluation:
* `ShellLive.render/1` — sets locale at the top of every LiveView render.
* `SidebarComponents.sidebar_content/1` — sets locale before the function
component's HEEx (runs in the same process, may be called outside
the parent render cycle via `send_update`).
* `MenuBar.mount/1` and `MenuBar.handle_info({:set_ui_locale, _})` — set
locale in the separate menu-bar process which has its own render cycle.
Violating this invariant causes `current/0` to return a stale or `nil`
locale, producing untranslated UI text.
Direct use of `Process.put(:bds_ui_locale, _)` or
`Process.get(:bds_ui_locale)` is forbidden outside this module.
"""

View File

@@ -15,6 +15,7 @@ defmodule BDS.Embeddings do
@duplicate_threshold 0.92
@exact_match_score 0.999999
@key_batch_size 199
def model_id, do: configured_backend().model_info().model_id
def dimensions, do: configured_backend().model_info().dimensions
@@ -73,7 +74,24 @@ defmodule BDS.Embeddings do
order_by: [asc: post.created_at, asc: post.slug]
)
Enum.each(posts, &sync_post_if_enabled(&1, refresh_index: false))
existing_keys = preload_keys_by_post_id(project_id, Enum.map(posts, & &1.id))
base_label = max_label_value()
{rows, _next_label} =
Enum.reduce(posts, {[], base_label + 1}, fn post, {acc, next_label} ->
existing_key = Map.get(existing_keys, post.id)
case compute_key_data(post, existing_key, next_label) do
:skip ->
{acc, next_label}
{:upsert, row} ->
bump = if existing_key, do: 0, else: 1
{[row | acc], next_label + bump}
end
end)
batch_upsert_keys(rows)
:ok = rebuild_snapshot(project_id)
{:ok, Enum.map(posts, & &1.id)}
else
@@ -104,13 +122,28 @@ defmodule BDS.Embeddings do
where: key.project_id == ^project_id and key.post_id not in ^post_ids
)
existing_keys = preload_keys_by_post_id(project_id)
base_label = max_label_value()
{rows, _next_label} =
posts
|> Enum.with_index(1)
|> Enum.each(fn {post, index} ->
sync_post_if_enabled(post, refresh_index: false)
|> Enum.reduce({[], base_label + 1}, fn {post, index}, {acc, next_label} ->
:ok = report_rebuild_progress(on_progress, index, total_posts, "embedding entries")
existing_key = Map.get(existing_keys, post.id)
case compute_key_data(post, existing_key, next_label) do
:skip ->
{acc, next_label}
{:upsert, row} ->
bump = if existing_key, do: 0, else: 1
{[row | acc], next_label + bump}
end
end)
batch_upsert_keys(rows)
:ok = report_rebuild_phase(on_progress, 0.99, "Persisting embedding snapshot")
:ok = rebuild_snapshot(project_id)
{:ok, post_ids}
@@ -196,6 +229,53 @@ defmodule BDS.Embeddings do
end
end
defp preload_keys_by_post_id(project_id) do
Repo.all(from key in Key, where: key.project_id == ^project_id)
|> Map.new(&{&1.post_id, &1})
end
defp preload_keys_by_post_id(project_id, post_ids) do
Repo.all(
from key in Key,
where: key.project_id == ^project_id and key.post_id in ^post_ids
)
|> Map.new(&{&1.post_id, &1})
end
defp max_label_value do
Repo.one(from key in Key, select: max(key.label)) || 0
end
defp compute_key_data(%Post{} = post, existing_key, next_label) do
body = resolve_post_body(post)
raw_text = compose_embedding_source(post.title, body)
content_hash = hash_text(raw_text)
if existing_key && existing_key.content_hash == content_hash do
:skip
else
{:ok, vector} = embed_text(raw_text, post.language)
label = if existing_key, do: existing_key.label, else: next_label
{:upsert, [label, post.id, post.project_id, content_hash, Jason.encode!(vector)]}
end
end
defp batch_upsert_keys([]), do: :ok
defp batch_upsert_keys(rows) do
rows
|> Enum.chunk_every(@key_batch_size)
|> Enum.each(fn chunk ->
placeholders = Enum.map_join(chunk, ", ", fn _ -> "(?, ?, ?, ?, ?)" end)
params = List.flatten(chunk)
Repo.query!(
"INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector) VALUES #{placeholders} ON CONFLICT(label) DO UPDATE SET content_hash = excluded.content_hash, vector = excluded.vector",
params
)
end)
end
def remove_post(post_id) when is_binary(post_id) do
project_id =
case Repo.get_by(Key, post_id: post_id) do
@@ -227,23 +307,24 @@ defmodule BDS.Embeddings do
order_by: [asc: post.created_at, asc: post.slug]
)
Enum.each(posts, fn post ->
body = resolve_post_body(post)
content_hash = hash_text(compose_embedding_source(post.title, body))
existing_keys = preload_keys_by_post_id(project_id)
base_label = max_label_value()
case Repo.get_by(Key, post_id: post.id, project_id: project_id) do
%Key{content_hash: ^content_hash} ->
:ok
{rows, _next_label} =
Enum.reduce(posts, {[], base_label + 1}, fn post, {acc, next_label} ->
existing_key = Map.get(existing_keys, post.id)
_other ->
:ok =
sync_post_if_enabled(
%{post | content: if(post.content in [nil, ""], do: body, else: post.content)},
refresh_index: false
)
case compute_key_data(post, existing_key, next_label) do
:skip ->
{acc, next_label}
{:upsert, row} ->
bump = if existing_key, do: 0, else: 1
{[row | acc], next_label + bump}
end
end)
batch_upsert_keys(rows)
:ok = rebuild_snapshot(project_id)
indexed =

View File

@@ -118,7 +118,7 @@ defmodule BDS.Generation.Data do
main = String.downcase(to_string(main_language || ""))
Enum.map(posts, fn post ->
post_language = String.downcase(to_string(Map.get(post, :language) || ""))
post_language = String.downcase(to_string(post.language || ""))
effective_language = if post_language == "", do: main, else: post_language
cond do
@@ -373,18 +373,18 @@ defmodule BDS.Generation.Data do
excerpt: translation.excerpt,
content: nil,
status: :published,
author: Map.get(post, :author),
author: post.author,
created_at: post.created_at,
updated_at: translation.updated_at,
published_at: translation.published_at || post.published_at,
file_path: translation.file_path,
tags: Map.get(post, :tags, []),
categories: Map.get(post, :categories, []),
template_slug: Map.get(post, :template_slug),
tags: post.tags,
categories: post.categories,
template_slug: post.template_slug,
language: translation.language,
do_not_translate: Map.get(post, :do_not_translate, false),
do_not_translate: post.do_not_translate,
translation_source_slug: post.slug,
translation_canonical_language: Map.get(post, :language),
translation_canonical_language: post.language,
translation_file_path: translation.file_path
}
end

View File

@@ -21,7 +21,7 @@ defmodule BDS.Generation.Outputs do
Enum.reject(route_posts, fn post ->
is_binary(Map.get(post, :translation_source_slug)) and
MapSet.member?(subtree_languages, to_string(Map.get(post, :language)))
MapSet.member?(subtree_languages, to_string(post.language))
end)
end
@@ -80,10 +80,12 @@ defmodule BDS.Generation.Outputs do
def category_route_paths(plan, posts_by_category, route_language) do
if :category in plan.sections do
Enum.flat_map(posts_by_category, fn {category, posts} ->
post_count = length(posts)
paginated_archive_paths(
route_language,
["category", archive_route_segment(category)],
length(posts),
post_count,
plan.max_posts_per_page
)
end)
@@ -96,10 +98,12 @@ defmodule BDS.Generation.Outputs do
def tag_route_paths(plan, posts_by_tag, route_language) do
if :tag in plan.sections do
Enum.flat_map(posts_by_tag, fn {tag, posts} ->
post_count = length(posts)
paginated_archive_paths(
route_language,
["tag", archive_route_segment(tag)],
length(posts),
post_count,
plan.max_posts_per_page
)
end)
@@ -113,10 +117,12 @@ defmodule BDS.Generation.Outputs do
if :date in plan.sections do
year_paths =
Enum.flat_map(post_index.posts_by_year, fn {year, posts} ->
post_count = length(posts)
paginated_archive_paths(
route_language,
[Integer.to_string(year)],
length(posts),
post_count,
plan.max_posts_per_page
)
end)
@@ -124,11 +130,12 @@ defmodule BDS.Generation.Outputs do
month_paths =
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
[year, month] = String.split(year_month, "/", parts: 2)
post_count = length(posts)
paginated_archive_paths(
route_language,
[year, month],
length(posts),
post_count,
plan.max_posts_per_page
)
end)
@@ -136,11 +143,12 @@ defmodule BDS.Generation.Outputs do
day_paths =
Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} ->
[year, month, day] = String.split(year_month_day, "/", parts: 3)
post_count = length(posts)
paginated_archive_paths(
route_language,
[year, month, day],
length(posts),
post_count,
plan.max_posts_per_page
)
end)
@@ -424,11 +432,11 @@ defmodule BDS.Generation.Outputs do
title: post.title,
content: body,
slug: post.slug,
language: Map.get(post, :language),
language: post.language,
excerpt: post.excerpt,
_post_record: post
},
fn -> render_post_page(post.title, body, post.slug, Map.get(post, :language)) end
fn -> render_post_page(post.title, body, post.slug, post.language) end
)}
end)
end)
@@ -552,11 +560,11 @@ defmodule BDS.Generation.Outputs do
title: post.title,
content: body,
slug: post.slug,
language: Map.get(post, :language),
language: post.language,
excerpt: post.excerpt,
_post_record: post
},
fn -> render_post_page(post.title, body, post.slug, Map.get(post, :language)) end
fn -> render_post_page(post.title, body, post.slug, post.language) end
)}
end)
end)

View File

@@ -75,7 +75,7 @@ defmodule BDS.Generation.Sitemap do
page_path = Paths.relative_path_to_url_path(Paths.page_output_path(post.slug, nil))
languages =
if Paths.truthy_flag?(Map.get(post, :do_not_translate)),
if Paths.truthy_flag?(post.do_not_translate),
do: [plan.language],
else: all_languages

View File

@@ -34,7 +34,7 @@ defmodule BDS.Generation.Validation do
post_file_path:
source_full_path(
project_data_dir,
Map.get(post, :translation_file_path) || Map.get(post, :file_path)
Map.get(post, :translation_file_path) || post.file_path
),
generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0)
}
@@ -53,7 +53,7 @@ defmodule BDS.Generation.Validation do
%{
post_url_path: relative_path_to_url_path(relative_path),
post_file_path: source_full_path(project_data_dir, Map.get(post, :file_path)),
post_file_path: source_full_path(project_data_dir, post.file_path),
generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0)
}
end)

View File

@@ -605,6 +605,6 @@ defmodule BDS.ImportExecution do
defp project_default_author(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
Map.get(metadata, :default_author)
metadata.default_author
end
end

View File

@@ -106,7 +106,7 @@ defmodule BDS.Media do
|> Repo.insert!()
end) do
{:ok, media} ->
:ok = write_sidecar(project, media)
log_sidecar_error(write_sidecar(project, media), media.id)
log_thumbnail_error(ensure_thumbnails(project, media), media.id)
:ok = Search.sync_media(media)
{:ok, media}
@@ -148,7 +148,7 @@ defmodule BDS.Media do
|> Repo.update!()
end) do
{:ok, updated_media} ->
:ok = write_sidecar(project, updated_media)
log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
:ok = Search.sync_media(updated_media)
{:ok, updated_media}
@@ -240,7 +240,7 @@ defmodule BDS.Media do
|> Repo.insert_or_update!()
end) do
{:ok, updated_translation} ->
:ok = write_translation_sidecar(project, media, updated_translation)
log_sidecar_error(write_translation_sidecar(project, media, updated_translation), media.id)
:ok = Search.sync_media(media.id)
{:ok, updated_translation}
@@ -275,7 +275,7 @@ defmodule BDS.Media do
)
:ok = Search.sync_media(media)
:ok = write_sidecar(project, media)
log_sidecar_error(write_sidecar(project, media), media.id)
{:ok, true}
{:error, changeset} ->
@@ -322,7 +322,7 @@ defmodule BDS.Media do
end) do
{:ok, updated_media} ->
_ = File.rm(previous_destination_backup)
:ok = write_sidecar(project, updated_media)
log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
log_thumbnail_error(ensure_thumbnails(project, updated_media), updated_media.id)
:ok = Search.sync_media(updated_media)
{:ok, updated_media}
@@ -350,4 +350,10 @@ defmodule BDS.Media do
defp log_thumbnail_error({:error, reason}, media_id) do
Logger.warning("Thumbnail generation failed for media #{media_id}: #{inspect(reason)}")
end
defp log_sidecar_error(:ok, _media_id), do: :ok
defp log_sidecar_error({:error, reason}, media_id) do
Logger.warning("Sidecar write failed for media #{media_id}: #{inspect(reason)}")
end
end

View File

@@ -1,6 +1,8 @@
defmodule BDS.Media.Linking do
@moduledoc false
require Logger
import Ecto.Query
alias BDS.Media.Media
@@ -64,7 +66,7 @@ defmodule BDS.Media.Linking do
end
end) do
{:ok, _result} ->
:ok = Sidecars.write_sidecar(project, media)
log_sidecar_error(Sidecars.write_sidecar(project, media), media.id)
{:ok, :linked}
{:error, reason} ->
@@ -93,7 +95,7 @@ defmodule BDS.Media.Linking do
:ok
end) do
{:ok, :ok} ->
:ok = Sidecars.write_sidecar(project, media)
log_sidecar_error(Sidecars.write_sidecar(project, media), media.id)
{:ok, :unlinked}
{:error, reason} ->
@@ -112,6 +114,12 @@ defmodule BDS.Media.Linking do
)
end
defp log_sidecar_error(:ok, _media_id), do: :ok
defp log_sidecar_error({:error, reason}, media_id) do
Logger.warning("Sidecar write failed for media #{media_id}: #{inspect(reason)}")
end
defp next_sort_order(media_id) do
case Repo.one(
from pm in PostMedia,

View File

@@ -18,10 +18,9 @@ defmodule BDS.Media.Sidecars do
alias BDS.Search
alias BDS.Sidecar
@spec write_sidecar(BDS.Projects.Project.t(), Media.t()) :: :ok
@spec write_sidecar(BDS.Projects.Project.t(), Media.t()) :: :ok | {:error, File.posix()}
def write_sidecar(project, media) do
path = Path.join(Projects.project_data_dir(project), media.sidecar_path)
:ok = File.mkdir_p(Path.dirname(path))
atomic_write(
path,
@@ -45,7 +44,8 @@ defmodule BDS.Media.Sidecars do
)
end
@spec write_translation_sidecar(BDS.Projects.Project.t(), Media.t(), Translation.t()) :: :ok
@spec write_translation_sidecar(BDS.Projects.Project.t(), Media.t(), Translation.t()) ::
:ok | {:error, File.posix()}
def write_translation_sidecar(project, media, translation) do
path =
Path.join(
@@ -53,8 +53,6 @@ defmodule BDS.Media.Sidecars do
translation_sidecar_path(media, translation.language)
)
:ok = File.mkdir_p(Path.dirname(path))
atomic_write(
path,
Sidecar.serialize_document([
@@ -189,8 +187,7 @@ defmodule BDS.Media.Sidecars do
media ->
project = Projects.get_project!(media.project_id)
:ok = write_sidecar(project, media)
:ok
write_sidecar(project, media)
end
end
@@ -224,8 +221,11 @@ defmodule BDS.Media.Sidecars do
%Translation{} = translation ->
media = Repo.get!(Media, translation.translation_for)
project = Projects.get_project!(media.project_id)
:ok = write_translation_sidecar(project, media, translation)
{:ok, translation}
case write_translation_sidecar(project, media, translation) do
:ok -> {:ok, translation}
{:error, reason} -> {:error, reason}
end
end
end

View File

@@ -252,7 +252,7 @@ defmodule BDS.Posts.AutoTranslation do
end
defp configured_languages(metadata) do
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, []))
([metadata.main_language] ++ metadata.blog_languages)
|> Enum.map(&normalize_language/1)
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq()

View File

@@ -312,7 +312,7 @@ defmodule BDS.Posts.TranslationValidation do
defp legacy_missing_entries(source_posts, translation_rows, metadata) do
configured_languages =
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, []))
([metadata.main_language] ++ metadata.blog_languages)
|> Enum.map(&do_normalize_language/1)
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq()
@@ -444,7 +444,7 @@ defmodule BDS.Posts.TranslationValidation do
language = do_normalize_language(source_post.language)
if language == "" do
do_normalize_language(Map.get(metadata, :main_language))
do_normalize_language(metadata.main_language)
else
language
end

View File

@@ -101,7 +101,7 @@ defmodule BDS.Preview do
with :ok <- ensure_running(state.current, project_id),
{:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
body =
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do
case Rendering.render_post_page(project_id, payload.template_slug, payload) do
{:ok, rendered} -> rendered
{:error, _reason} -> render_draft(payload)
end
@@ -178,7 +178,7 @@ defmodule BDS.Preview do
defp resolve_draft_request(project_id, post_id, query_params) do
with {:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
body =
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do
case Rendering.render_post_page(project_id, payload.template_slug, payload) do
{:ok, rendered} -> rendered
{:error, _reason} -> render_draft(payload)
end

View File

@@ -28,8 +28,11 @@ defmodule BDS.PreviewAssets do
end)
|> Enum.filter(&File.regular?/1)
|> Enum.sort()
|> Enum.map(fn path ->
{Path.relative_to(path, @preview_root), File.read!(path)}
|> Enum.flat_map(fn path ->
case File.read(path) do
{:ok, contents} -> [{Path.relative_to(path, @preview_root), contents}]
{:error, _reason} -> []
end
end)
end

View File

@@ -46,6 +46,7 @@ defmodule BDS.Publishing do
{:reply, Repo.get(PublishJob, job_id), state}
end
@impl true
def handle_call({:update_job, job_id, attrs}, _from, state) do
with %PublishJob{} = job <- Repo.get(PublishJob, job_id) do
attrs = Map.put(attrs, :updated_at, Persistence.now_ms())
@@ -55,6 +56,7 @@ defmodule BDS.Publishing do
{:reply, :ok, state}
end
@impl true
def handle_call({:should_upload_scp_file, upload_key, local_mtime}, _from, state) do
should_upload? =
case state.scp_uploads[upload_key] do
@@ -65,10 +67,12 @@ defmodule BDS.Publishing do
{:reply, should_upload?, state}
end
@impl true
def handle_call({:mark_uploaded_scp_file, upload_key, local_mtime}, _from, state) do
{:reply, :ok, put_in(state, [:scp_uploads, upload_key], local_mtime)}
end
@impl true
def handle_call({:upload_site, project_id, credentials, targets, opts}, _from, state) do
job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic]))
uploader = build_uploader(Keyword.put_new(opts, :project_id, project_id))

View File

@@ -77,18 +77,17 @@ defmodule BDS.ReleasePackaging do
defp reset_output(metadata) do
File.rm_rf!(metadata.payload_root)
File.rm_rf!(metadata.archive_path)
File.mkdir_p!(metadata.output_dir)
:ok
File.mkdir_p(metadata.output_dir)
end
defp copy_release(source, destination) do
File.mkdir_p!(Path.dirname(destination))
with :ok <- File.mkdir_p(Path.dirname(destination)) do
case File.cp_r(source, destination) do
{:ok, _files} -> :ok
{:error, reason, _file} -> {:error, reason}
end
end
end
defp write_manifest(metadata) do
manifest = %{
@@ -102,8 +101,7 @@ defmodule BDS.ReleasePackaging do
}
manifest_path = Path.join(metadata.payload_root, "manifest.json")
File.write!(manifest_path, Jason.encode!(manifest, pretty: true))
:ok
File.write(manifest_path, Jason.encode!(manifest, pretty: true))
end
defp create_archive(%Metadata{platform: :windows} = metadata) do

View File

@@ -135,28 +135,42 @@ defmodule BDS.Rendering.Filters do
""
_id ->
template_source = Liquex.FileSystem.read_template_file(context.file_system, template_path)
case BDS.Rendering.FileSystem.try_read(context.file_system, template_path) do
{:ok, template_source} ->
render_macro_source(template_path, template_source, assigns, context)
case Liquex.parse(template_source) do
{:ok, template_ast} ->
isolated_context = Liquex.Context.new_isolated_subscope(context, assigns)
try do
{result, _context} = Liquex.render!(template_ast, isolated_context)
IO.iodata_to_binary(result)
rescue
e in Liquex.Error ->
{:error, :enoent} ->
require Logger
Logger.warning("Macro template render failed (#{template_path}): #{e.message}")
Logger.warning("Macro template not found: #{template_path}")
""
end
end
end
defp render_macro_source(template_path, template_source, assigns, context) do
with {:ok, template_ast} <- Liquex.parse(template_source),
{:ok, rendered} <- safe_liquex_render(template_ast, context, assigns) do
rendered
else
{:error, reason, line} ->
require Logger
Logger.warning("Macro template parse failed (#{template_path}): #{reason} at line #{line}")
""
{:error, message} ->
require Logger
Logger.warning("Macro template render failed (#{template_path}): #{message}")
""
end
end
defp safe_liquex_render(template_ast, context, assigns) do
isolated_context = Liquex.Context.new_isolated_subscope(context, assigns)
{result, _context} = Liquex.render!(template_ast, isolated_context)
{:ok, IO.iodata_to_binary(result)}
rescue
e in Liquex.Error -> {:error, e.message}
end
defp render_markdown_html(markdown) do

View File

@@ -93,7 +93,16 @@ defmodule BDS.Rendering.TemplateSelection do
@spec render_template(String.t(), String.t(), map()) ::
{:ok, String.t()} | {:error, String.t()}
def render_template(project_id, source, assigns) do
with {:ok, template_ast} <- Liquex.parse(source) do
with {:ok, template_ast} <- Liquex.parse(source),
{:ok, _rendered} = ok <- safe_liquex_render(template_ast, project_id, assigns) do
ok
else
{:error, reason, line} when is_integer(line) -> {:error, "#{reason} at line #{line}"}
{:error, _message} = error -> error
end
end
defp safe_liquex_render(template_ast, project_id, assigns) do
project = Projects.get_project!(project_id)
context =
@@ -103,35 +112,26 @@ defmodule BDS.Rendering.TemplateSelection do
file_system: FileSystem.new(StarterTemplates.template_roots(project))
)
try do
{result, _context} = Liquex.render!(template_ast, context)
{:ok, IO.iodata_to_binary(result)}
rescue
e in Liquex.Error -> {:error, e.message}
end
else
{:error, reason, line} -> {:error, "#{reason} at line #{line}"}
end
end
defp load_bundled_template_source(project, kind, slug) do
desired_slug = bundled_template_slug(kind, slug)
if is_binary(desired_slug) do
file_system = project |> StarterTemplates.template_roots() |> FileSystem.new()
source = Liquex.FileSystem.read_template_file(file_system, desired_slug)
with true <- is_binary(desired_slug),
file_system = project |> StarterTemplates.template_roots() |> FileSystem.new(),
{:ok, source} <- FileSystem.try_read(file_system, desired_slug) do
case Frontmatter.parse_document(source) do
{:ok, %{body: body}} -> {:ok, body}
{:error, :invalid_frontmatter} -> {:ok, source}
end
else
{:error, :template_not_found}
false -> {:error, :template_not_found}
{:error, :enoent} -> {:error, :template_not_found}
end
rescue
error in [Liquex.Error] ->
_ = error
{:error, :template_not_found}
end
defp maybe_load_bundled_template_source(project, kind, slug, template, reason, error)

View File

@@ -4,6 +4,8 @@ defmodule BDS.Templates do
including slug derivation, status transitions, and filesystem synchronization.
"""
require Logger
import Ecto.Query
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
@@ -184,10 +186,18 @@ defmodule BDS.Templates do
templates =
template_paths
|> Enum.with_index(1)
|> Enum.map(fn {path, index} ->
template = upsert_template_from_file(project_id, project, path)
|> Enum.flat_map(fn {path, index} ->
result = upsert_template_from_file(project_id, project, path)
:ok = report_rebuild_progress(on_progress, index, total_files, "template files")
template
case result do
{:ok, template} ->
[template]
{:error, reason} ->
Logger.warning("Skipping template #{path}: #{inspect(reason)}")
[]
end
end)
remove_stale_published_templates(project_id, project, template_paths)
@@ -241,10 +251,9 @@ defmodule BDS.Templates do
project = Projects.get_project!(template.project_id)
full_path = Path.join(Projects.project_data_dir(project), template.file_path)
if File.exists?(full_path) do
{:ok, upsert_template_from_file(template.project_id, project, full_path)}
else
{:error, :not_found}
case upsert_template_from_file(template.project_id, project, full_path) do
{:ok, _template} = ok -> ok
{:error, _reason} -> {:error, :not_found}
end
end
end
@@ -278,10 +287,9 @@ defmodule BDS.Templates do
project = Projects.get_project!(project_id)
full_path = Path.join(Projects.project_data_dir(project), relative_path)
if File.exists?(full_path) do
{:ok, upsert_template_from_file(project_id, project, full_path)}
else
{:error, :not_found}
case upsert_template_from_file(project_id, project, full_path) do
{:ok, _template} = ok -> ok
{:error, _reason} -> {:error, :not_found}
end
end
@@ -448,13 +456,12 @@ defmodule BDS.Templates do
body = published_template_body(original_template)
new_full_path = full_file_path(updated_template.project_id, updated_template.file_path)
result =
Persistence.atomic_write(new_full_path, serialize_template_file(updated_template, body))
if result == :ok and original_template.file_path != updated_template.file_path do
case Persistence.atomic_write(new_full_path, serialize_template_file(updated_template, body)) do
:ok when original_template.file_path != updated_template.file_path ->
delete_file_if_present(original_template.project_id, original_template.file_path)
else
result
other ->
other
end
end
@@ -494,9 +501,10 @@ defmodule BDS.Templates do
end
defp upsert_template_from_file(project_id, project, path) do
contents = File.read!(path)
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
relative_path = Path.relative_to(path, Projects.project_data_dir(project))
with {:ok, contents} <- File.read(path),
{:ok, %{fields: fields}} <- Frontmatter.parse_document(contents) do
now = Persistence.now_ms()
attrs = %{
@@ -518,7 +526,8 @@ defmodule BDS.Templates do
template
|> Template.changeset(attrs)
|> Repo.insert_or_update!()
|> Repo.insert_or_update()
end
end
defp remove_stale_published_templates(project_id, project, template_paths) do

View File

@@ -821,11 +821,13 @@ defmodule BDS.UI.Sidebar do
# ---------------------------------------------------------------------------
defp build_post_section(title, status, posts, translation_counts, published_meta?) do
post_count = length(posts)
%{
id: Atom.to_string(status),
title: title,
status: Atom.to_string(status),
count: length(posts),
count: post_count,
items:
Enum.map(posts, fn post ->
%{

View File

@@ -0,0 +1,9 @@
defmodule BDS.Repo.Migrations.AddChatConversationSurfaceState do
use Ecto.Migration
def change do
alter table(:chat_conversations) do
add :surface_state, :text
end
end
end

View File

@@ -1477,6 +1477,39 @@ defmodule BDS.AITest do
assert Enum.map(messages, & &1.role) == [:user]
end
test "get_surface_state and put_surface_state persist and restore surface UI state" do
assert {:ok, conversation} = BDS.AI.start_chat(%{title: "Surface State", model: "gpt-4.1"})
surface_data = %{"msg-1-surface-0" => %{"query" => "hello"}}
surface_tabs = %{"msg-1-surface-1" => 2}
dismissed = MapSet.new(["msg-1-surface-0"])
assert {:ok, _state} =
BDS.AI.put_surface_state(
conversation.id,
surface_data,
surface_tabs,
dismissed
)
loaded = BDS.AI.get_surface_state(conversation.id)
assert loaded["surface_data"] == surface_data
assert loaded["surface_tabs"] == surface_tabs
assert MapSet.new(loaded["dismissed_surfaces"]) == dismissed
end
test "get_surface_state returns empty map for conversation without surface state" do
assert {:ok, conversation} = BDS.AI.start_chat(%{title: "No Surface State", model: "gpt-4.1"})
loaded = BDS.AI.get_surface_state(conversation.id)
assert loaded == %{}
end
test "get_surface_state returns empty map for unknown conversation" do
loaded = BDS.AI.get_surface_state("nonexistent-id")
assert loaded == %{}
end
defp create_project_fixture(name) do
temp_dir = Path.join(System.tmp_dir!(), "bds-ai-#{System.unique_integer([:positive])}")
on_exit(fn -> File.rm_rf(temp_dir) end)

View File

@@ -0,0 +1,22 @@
defmodule BDS.CSM028BroadRescueTest do
use ExUnit.Case, async: true
describe "source-level: no broad rescue in render_macro_template" do
test "filters.ex has no rescue _error or rescue _ clauses" do
source = File.read!("lib/bds/rendering/filters.ex")
refute source =~ ~r/rescue\s+_\w*\s*->/, "Found broad rescue clause in filters.ex"
end
test "filters.ex does not call read_template_file (which raises)" do
source = File.read!("lib/bds/rendering/filters.ex")
refute source =~ "read_template_file",
"render_macro_template should use try_read, not read_template_file"
end
test "filters.ex uses FileSystem.try_read for macro templates" do
source = File.read!("lib/bds/rendering/filters.ex")
assert source =~ "FileSystem.try_read"
end
end
end

View File

@@ -0,0 +1,55 @@
defmodule BDS.CSM029LengthInGuardsTest do
use ExUnit.Case, async: true
@outputs_path "lib/bds/generation/outputs.ex"
@sidebar_path "lib/bds/ui/sidebar.ex"
describe "source-level: length/1 bound before use in loops" do
test "outputs.ex route path functions bind length before passing to paginated_archive_paths" do
source = File.read!(@outputs_path)
refute source =~ ~r/paginated_archive_paths\([^)]*length\(posts\)/,
"length(posts) should be bound to a variable before passing to paginated_archive_paths"
end
test "sidebar.ex build_post_section binds length before map literal" do
source = File.read!(@sidebar_path)
lines =
source
|> String.split("\n")
|> Enum.with_index(1)
build_section_lines =
Enum.filter(lines, fn {line, _} -> line =~ "defp build_post_section" end)
for {_line, line_no} <- build_section_lines do
context =
lines
|> Enum.drop(line_no - 1)
|> Enum.take(30)
|> Enum.map(fn {l, _} -> l end)
|> Enum.join("\n")
refute context =~ ~r/count: length\(/,
"length() should be pre-bound, not inline in map literal"
end
end
test "no length/1 calls remain inline inside Enum.flat_map callbacks in route path functions" do
source = File.read!(@outputs_path)
route_fns = ["category_route_paths", "tag_route_paths", "date_route_paths"]
for fn_name <- route_fns do
fn_match = Regex.run(~r/def #{fn_name}\(.*?\n end/s, source)
assert fn_match, "Expected to find #{fn_name} in outputs.ex"
[fn_body] = fn_match
refute fn_body =~ ~r/\blength\(posts\)(?!\s)/,
"#{fn_name} should use pre-bound post_count instead of inline length(posts)"
end
end
end
end

View File

@@ -0,0 +1,42 @@
defmodule BDS.CSM030UncheckedMkdirTest do
use ExUnit.Case, async: true
describe "source-level: no unchecked File.mkdir_p" do
test "sidecars.ex has no bare File.mkdir_p calls" do
source = File.read!("lib/bds/media/sidecars.ex")
refute source =~ ~r/:ok\s*=\s*File\.mkdir_p/
refute source =~ ~r/File\.mkdir_p!/
end
test "release_packaging.ex has no File.mkdir_p! calls" do
source = File.read!("lib/bds/release_packaging.ex")
refute source =~ ~r/File\.mkdir_p!/
end
test "thumbnails.ex mkdir_p is inside a with chain" do
source = File.read!("lib/bds/media/thumbnails.ex")
refute source =~ ~r/:ok\s*=\s*File\.mkdir_p/
refute source =~ ~r/File\.mkdir_p!/
end
end
describe "source-level: sidecar write errors are handled" do
test "media.ex does not assert :ok on write_sidecar" do
source = File.read!("lib/bds/media.ex")
refute source =~ ~r/:ok\s*=\s*write_sidecar/
refute source =~ ~r/:ok\s*=\s*write_translation_sidecar/
end
test "linking.ex does not assert :ok on write_sidecar" do
source = File.read!("lib/bds/media/linking.ex")
refute source =~ ~r/:ok\s*=\s*Sidecars\.write_sidecar/
end
end
describe "source-level: release_packaging.ex write_manifest" do
test "uses File.write not File.write!" do
source = File.read!("lib/bds/release_packaging.ex")
refute source =~ ~r/File\.write!/
end
end
end

View File

@@ -0,0 +1,68 @@
defmodule BDS.CSM031TryRescueTest do
use ExUnit.Case, async: true
describe "source-level: no inline try/rescue around Liquex.render!" do
test "filters.ex has no try/rescue block in render_macro_source" do
source = File.read!("lib/bds/rendering/filters.ex")
refute source =~ ~r/try do\s+.*Liquex\.render!/s,
"render_macro_source should use safe_liquex_render helper, not inline try/rescue"
end
test "filters.ex isolates Liquex.render! rescue in safe_liquex_render" do
source = File.read!("lib/bds/rendering/filters.ex")
assert source =~ "defp safe_liquex_render"
end
test "template_selection.ex has no try/rescue block in render_template" do
source = File.read!("lib/bds/rendering/template_selection.ex")
lines = String.split(source, "\n")
in_render_template =
lines
|> Enum.drop_while(&(not String.contains?(&1, "def render_template(")))
|> Enum.take_while(&(not String.match?(&1, ~r/^\s+def[p]?\s/)))
body = Enum.join(in_render_template, "\n")
refute body =~ "try do", "render_template should not contain inline try/rescue"
end
test "template_selection.ex isolates Liquex.render! rescue in safe_liquex_render" do
source = File.read!("lib/bds/rendering/template_selection.ex")
assert source =~ "defp safe_liquex_render"
end
end
describe "source-level: no function-level rescue in load_bundled_template_source" do
test "template_selection.ex load_bundled_template_source has no rescue block" do
source = File.read!("lib/bds/rendering/template_selection.ex")
lines = String.split(source, "\n")
in_load_bundled =
lines
|> Enum.drop_while(&(not String.contains?(&1, "defp load_bundled_template_source(")))
|> Enum.take_while(fn line ->
not (String.match?(line, ~r/^\s+def[p]?\s/) and
not String.contains?(line, "load_bundled_template_source"))
end)
body = Enum.join(in_load_bundled, "\n")
refute body =~ ~r/^\s+rescue\b/m, "load_bundled_template_source should use with, not rescue"
end
test "template_selection.ex uses FileSystem.try_read instead of read_template_file" do
source = File.read!("lib/bds/rendering/template_selection.ex")
refute source =~ "read_template_file",
"should use FileSystem.try_read, not the raising read_template_file"
assert source =~ "FileSystem.try_read"
end
end
describe "source-level: shell_data.ex has no try/rescue" do
test "shell_data.ex contains no try/rescue blocks" do
source = File.read!("lib/bds/desktop/shell_data.ex")
refute source =~ ~r/\btry\s+do\b/, "shell_data.ex should not contain try/rescue blocks"
refute source =~ ~r/\brescue\b/, "shell_data.ex should not contain rescue clauses"
end
end
end

View File

@@ -0,0 +1,98 @@
defmodule BDS.CSM032MapGetPatternMatchTest do
use ExUnit.Case, async: true
@metadata_consumers [
"lib/bds/posts/translation_validation.ex",
"lib/bds/posts/auto_translation.ex",
"lib/bds/desktop/shell_commands.ex",
"lib/bds/desktop/shell_live/settings_editor/project_settings.ex",
"lib/bds/desktop/shell_live/settings_editor/style_editor.ex",
"lib/bds/desktop/shell_live/settings_editor/managed_categories.ex",
"lib/bds/desktop/shell_live/settings_editor/publishing_settings.ex",
"lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex",
"lib/bds/desktop/shell_live/import_editor/analysis_state.ex",
"lib/bds/import_execution.ex"
]
@metadata_atom_keys ~w(main_language blog_languages categories category_settings
publishing_preferences name description public_url default_author
max_posts_per_page blogmark_category pico_theme
semantic_similarity_enabled)
describe "source-level: no Map.get(metadata, :atom_key) on metadata struct" do
for file <- @metadata_consumers do
test "#{Path.basename(file)} uses dot access instead of Map.get for metadata atom keys" do
source = File.read!(unquote(file))
for key <- @metadata_atom_keys do
refute source =~ "Map.get(metadata, :#{key}",
"#{unquote(file)} should use metadata.#{key} instead of Map.get(metadata, :#{key})"
end
end
end
end
describe "source-level: overlay.ex uses pattern matching for known structures" do
test "delete_details uses pattern matching instead of Map.get" do
source = File.read!("lib/bds/desktop/overlay.ex")
refute source =~ "Map.get(delete_details,",
"overlay.ex should pattern match delete_details instead of using Map.get"
end
test "merge_details uses pattern matching instead of Map.get" do
source = File.read!("lib/bds/desktop/overlay.ex")
refute source =~ "Map.get(merge,",
"overlay.ex should pattern match merge_details instead of using Map.get"
end
test "media struct fields use dot access in to_insert_media_result" do
source = File.read!("lib/bds/desktop/overlay.ex")
refute source =~ ~r/to_insert_media_result.*Map\.get\(media/s &&
source =~ "defp to_insert_media_result",
"to_insert_media_result should use dot access for media fields"
end
test "media struct fields use dot access in to_gallery_image" do
source = File.read!("lib/bds/desktop/overlay.ex")
refute source =~ ~r/to_gallery_image.*Map\.get\(media/s &&
source =~ "defp to_gallery_image",
"to_gallery_image should use dot access for media fields"
end
end
describe "source-level: generation pipeline uses dot access for Post struct fields" do
test "outputs.ex uses post.language instead of Map.get(post, :language)" do
source = File.read!("lib/bds/generation/outputs.ex")
refute source =~ "Map.get(post, :language)",
"outputs.ex should use post.language"
end
test "data.ex uses dot access for Post struct fields in build_published_translation_variant" do
source = File.read!("lib/bds/generation/data.ex")
refute source =~ "Map.get(post, :author)",
"data.ex should use post.author"
refute source =~ "Map.get(post, :tags",
"data.ex should use post.tags"
refute source =~ "Map.get(post, :categories",
"data.ex should use post.categories"
refute source =~ "Map.get(post, :template_slug)",
"data.ex should use post.template_slug"
refute source =~ "Map.get(post, :do_not_translate",
"data.ex should use post.do_not_translate"
end
test "validation.ex uses post.file_path instead of Map.get(post, :file_path)" do
source = File.read!("lib/bds/generation/validation.ex")
refute source =~ "Map.get(post, :file_path)",
"validation.ex should use post.file_path"
end
test "sitemap.ex uses post.do_not_translate instead of Map.get" do
source = File.read!("lib/bds/generation/sitemap.ex")
refute source =~ "Map.get(post, :do_not_translate)",
"sitemap.ex should use post.do_not_translate"
end
end
end

View File

@@ -0,0 +1,206 @@
defmodule BDS.CSM033BatchInsertsTest do
use ExUnit.Case, async: false
import Ecto.Query
describe "source-level: embeddings.ex uses batch inserts instead of Enum.each + individual writes" do
setup do
source = File.read!("lib/bds/embeddings.ex")
%{source: source}
end
test "no Enum.each calling sync_post_if_enabled in bulk paths", %{source: source} do
refute source =~ "Enum.each(posts, &sync_post_if_enabled",
"bulk paths should not use Enum.each with sync_post_if_enabled"
refute source =~ ~r/Enum\.each\(fn \{post, index\} ->\n\s+sync_post_if_enabled/,
"bulk paths should not use Enum.each with sync_post_if_enabled"
end
test "bulk functions use batch_upsert_keys", %{source: source} do
assert source =~ "batch_upsert_keys(rows)",
"expected batch_upsert_keys to be called with collected rows"
end
test "bulk functions preload keys before the loop", %{source: source} do
assert source =~ "preload_keys_by_post_id(project_id)",
"expected keys to be preloaded in a single query"
end
test "batch_upsert_keys uses multi-row INSERT with ON CONFLICT upsert", %{source: source} do
assert source =~ "INSERT INTO embedding_keys",
"expected raw SQL batch INSERT for embedding keys"
assert source =~ "ON CONFLICT(label) DO UPDATE",
"expected ON CONFLICT upsert clause"
end
test "compute_key_data is used instead of individual Repo.insert_or_update", %{source: source} do
assert source =~ "compute_key_data(post, existing_key, next_label)",
"expected compute_key_data helper for row computation"
end
end
describe "source-level: search.ex already uses batch inserts" do
test "batch_insert_post_index uses multi-row VALUES" do
source = File.read!("lib/bds/search.ex")
assert source =~ "batch_insert_post_index"
assert source =~ ~r/INSERT INTO posts_fts.*VALUES.*\#\{placeholders\}/s
end
test "batch_insert_media_index uses multi-row VALUES" do
source = File.read!("lib/bds/search.ex")
assert source =~ "batch_insert_media_index"
assert source =~ ~r/INSERT INTO media_fts.*VALUES.*\#\{placeholders\}/s
end
end
describe "functional: batch operations produce correct results" do
defmodule FakeBackend do
@behaviour BDS.Embeddings.Backend
@impl true
def model_info, do: %{model_id: "fake/test-model", dimensions: 384}
@impl true
def embed(text, opts), do: BDS.Embeddings.Backends.InApp.embed(text, opts)
end
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir =
Path.join(System.tmp_dir!(), "bds-csm033-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "CSM033", data_path: temp_dir})
previous_config = Application.get_env(:bds, :embeddings)
Application.put_env(:bds, :embeddings, backend: FakeBackend)
on_exit(fn ->
if previous_config == nil do
Application.delete_env(:bds, :embeddings)
else
Application.put_env(:bds, :embeddings, previous_config)
end
end)
assert {:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{
semantic_similarity_enabled: true
})
%{project: project}
end
test "index_unindexed batch-inserts keys for multiple posts", %{project: project} do
posts =
for i <- 1..5 do
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Post #{i}",
content: "content for post number #{i} with unique words #{:rand.uniform(10000)}",
language: "en"
})
post
end
{:ok, indexed} = BDS.Embeddings.index_unindexed(project.id)
assert length(indexed) == 5
assert Enum.all?(posts, fn post -> post.id in indexed end)
keys =
BDS.Repo.all(
from(k in BDS.Embeddings.Key, where: k.project_id == ^project.id)
)
assert length(keys) == 5
labels = Enum.map(keys, & &1.label) |> Enum.sort()
assert labels == Enum.to_list(1..5)
end
test "rebuild_project updates stale keys via batch upsert", %{project: project} do
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Rebuild Target",
content: "original content for rebuild test",
language: "en"
})
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
original_key =
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
{:ok, _post} = BDS.Posts.update_post(post.id, %{content: "completely different content now"})
{:ok, rebuilt_ids} = BDS.Embeddings.rebuild_project(project.id)
assert post.id in rebuilt_ids
updated_key =
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
assert updated_key.label == original_key.label
assert updated_key.content_hash != original_key.content_hash
end
test "repair_posts batch-upserts for specified posts only", %{project: project} do
{:ok, post_a} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Repair A",
content: "content A",
language: "en"
})
{:ok, _post_b} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Repair B",
content: "content B",
language: "en"
})
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
{:ok, repaired} = BDS.Embeddings.repair_posts(project.id, [post_a.id])
assert repaired == [post_a.id]
keys =
BDS.Repo.all(
from(k in BDS.Embeddings.Key, where: k.project_id == ^project.id)
)
assert length(keys) == 2
end
test "index_unindexed skips posts with matching content hash", %{project: project} do
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Skip Test",
content: "unchanged content for skip test",
language: "en"
})
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
key_before =
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
key_after =
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
assert key_before.label == key_after.label
assert key_before.content_hash == key_after.content_hash
assert key_before.vector == key_after.vector
end
end
end

View File

@@ -0,0 +1,107 @@
defmodule BDS.CSM034FileReadBangTest do
use ExUnit.Case, async: false
import Ecto.Query
describe "source-level: no File.read! or File.write! in affected files" do
test "preview_assets.ex has no File.read!" do
source = File.read!("lib/bds/preview_assets.ex")
refute source =~ "File.read!", "preview_assets.ex should use File.read, not File.read!"
end
test "templates.ex has no File.read!" do
source = File.read!("lib/bds/templates.ex")
refute source =~ "File.read!", "templates.ex should use File.read, not File.read!"
end
test "templates.ex has no File.write!" do
source = File.read!("lib/bds/templates.ex")
refute source =~ "File.write!", "templates.ex should use File.write, not File.write!"
end
test "release_packaging.ex has no File.read! or File.write!" do
source = File.read!("lib/bds/release_packaging.ex")
refute source =~ "File.read!", "release_packaging.ex should use File.read, not File.read!"
refute source =~ "File.write!", "release_packaging.ex should use File.write, not File.write!"
end
end
describe "preview_assets.generated_outputs/0 handles read errors" do
test "returns results for readable files, skips unreadable ones" do
outputs = BDS.PreviewAssets.generated_outputs()
assert is_list(outputs)
assert Enum.all?(outputs, fn {path, body} -> is_binary(path) and is_binary(body) end)
end
end
describe "templates file error handling" do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-csm034-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "CSM034", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
test "rebuild skips unreadable template files without crashing", %{
project: project,
temp_dir: temp_dir
} do
templates_dir = Path.join(temp_dir, "templates")
File.mkdir_p!(templates_dir)
File.write!(Path.join(templates_dir, "good.liquid"), """
---
slug: good
kind: post
title: Good Template
---
<p>{{ content }}</p>
""")
File.write!(Path.join(templates_dir, "bad.liquid"), "no frontmatter here")
assert {:ok, templates} = BDS.Templates.rebuild_templates_from_files(project.id)
assert length(templates) == 1
assert hd(templates).slug == "good"
end
test "sync_template_from_file returns error when file is deleted", %{
project: project,
temp_dir: temp_dir
} do
templates_dir = Path.join(temp_dir, "templates")
File.mkdir_p!(templates_dir)
path = Path.join(templates_dir, "ephemeral.liquid")
File.write!(path, """
---
slug: ephemeral
kind: post
title: Ephemeral
---
<p>{{ content }}</p>
""")
{:ok, _templates} = BDS.Templates.rebuild_templates_from_files(project.id)
template =
BDS.Repo.one(
from(t in BDS.Templates.Template,
where: t.project_id == ^project.id and t.slug == "ephemeral"
)
)
File.rm!(path)
assert {:error, :not_found} = BDS.Templates.sync_template_from_file(template.id)
end
test "import_orphan_template_file returns error for missing file", %{project: project} do
assert {:error, :not_found} =
BDS.Templates.import_orphan_template_file(project.id, "templates/ghost.liquid")
end
end
end

View File

@@ -0,0 +1,86 @@
defmodule BDS.CSM035ProcessDictTest do
use ExUnit.Case, async: true
alias BDS.Desktop.UILocale
describe "source-level: no raw Process.put/get for :bds_ui_locale outside ui_locale.ex" do
test "no direct Process.put(:bds_ui_locale, ...) outside ui_locale.ex" do
elixir_files =
Path.wildcard("lib/**/*.ex")
|> Enum.reject(&(&1 == "lib/bds/desktop/ui_locale.ex"))
violations =
Enum.filter(elixir_files, fn path ->
source = File.read!(path)
source =~ "Process.put(:bds_ui_locale" or source =~ "Process.put(@key"
end)
assert violations == [],
"Raw Process.put(:bds_ui_locale) found outside ui_locale.ex: #{inspect(violations)}"
end
test "no direct Process.get(:bds_ui_locale, ...) outside ui_locale.ex" do
elixir_files =
Path.wildcard("lib/**/*.ex")
|> Enum.reject(&(&1 == "lib/bds/desktop/ui_locale.ex"))
violations =
Enum.filter(elixir_files, fn path ->
source = File.read!(path)
source =~ "Process.get(:bds_ui_locale" or source =~ "Process.delete(:bds_ui_locale"
end)
assert violations == [],
"Raw Process.get/delete(:bds_ui_locale) found outside ui_locale.ex: #{inspect(violations)}"
end
end
describe "source-level: render boundaries call UILocale.put before template evaluation" do
test "ShellLive.render/1 calls UILocale.put before index(assigns)" do
source = File.read!("lib/bds/desktop/shell_live.ex")
render_match = Regex.run(~r/def render\(assigns\).*?\n(.*?)\n(.*?)\n/s, source)
assert render_match, "could not find render/1 in shell_live.ex"
[_, first_line | _] = render_match
assert first_line =~ "UILocale.put", "render/1 must call UILocale.put on its first line"
end
test "sidebar_content/1 calls UILocale.put before HEEx" do
source = File.read!("lib/bds/desktop/shell_live/sidebar_components.ex")
match = Regex.run(~r/def sidebar_content\(assigns\).*?\n(.*?)\n/s, source)
assert match, "could not find sidebar_content/1"
[_, first_line | _] = match
assert first_line =~ "UILocale.put", "sidebar_content/1 must call UILocale.put on its first line"
end
test "MenuBar.mount/1 calls UILocale.put" do
source = File.read!("lib/bds/desktop/menu_bar.ex")
match = Regex.run(~r/def mount\(menu\).*?\n(.*?)\n/s, source)
assert match, "could not find mount/1 in menu_bar.ex"
[_, first_line | _] = match
assert first_line =~ "UILocale.put", "MenuBar.mount/1 must call UILocale.put on its first line"
end
end
describe "UILocale functional behavior" do
test "put/1 sets locale readable by current/0" do
UILocale.put("de")
assert UILocale.current() == "de"
end
test "with_locale/2 restores previous locale after block" do
UILocale.put("en")
UILocale.with_locale("fr", fn -> assert UILocale.current() == "fr" end)
assert UILocale.current() == "en"
end
test "with_locale/2 restores nil when no prior locale was set" do
Process.delete(:bds_ui_locale)
UILocale.with_locale("it", fn -> assert UILocale.current() == "it" end)
assert UILocale.current() == nil
end
test "put/1 returns :ok" do
assert UILocale.put("en") == :ok
end
end
end

View File

@@ -0,0 +1,45 @@
defmodule BDS.CSM036ImplTrueTest do
use ExUnit.Case, async: true
@publishing_source File.read!("lib/bds/publishing.ex")
describe "CSM-036: @impl true on GenServer callbacks" do
test "every handle_call clause has @impl true" do
lines = String.split(@publishing_source, "\n")
handle_call_lines =
lines
|> Enum.with_index(1)
|> Enum.filter(fn {line, _idx} ->
String.contains?(line, "def handle_call(")
end)
assert length(handle_call_lines) >= 5, "expected at least 5 handle_call clauses"
for {_line, idx} <- handle_call_lines do
preceding = Enum.at(lines, idx - 2)
assert String.contains?(preceding, "@impl true"),
"handle_call at line #{idx} missing @impl true (preceding line: #{inspect(preceding)})"
end
end
test "no handle_cast, handle_info, or terminate without @impl true" do
lines = String.split(@publishing_source, "\n")
callback_lines =
lines
|> Enum.with_index(1)
|> Enum.filter(fn {line, _idx} ->
Regex.match?(~r/^\s+def (handle_cast|handle_info|terminate)\(/, line)
end)
for {_line, idx} <- callback_lines do
preceding = Enum.at(lines, idx - 2)
assert String.contains?(preceding, "@impl true"),
"callback at line #{idx} missing @impl true"
end
end
end
end

View File

@@ -206,11 +206,17 @@ defmodule BDS.Desktop.OverlayTest do
}
],
delete_details: %{
title: "Delete Media",
entity_name: "Street Scene",
entity_type: "media",
reference_list: ["Photo Walk", "Trip Notes"]
},
merge_details: %{target: "travel", count: 3}
merge_details: %{
target: "travel",
count: 3,
title: "Merge 3 tags into travel?",
message: "Cannot be undone."
}
}
end
end

View File

@@ -4043,6 +4043,87 @@ defmodule BDS.Desktop.ShellLiveTest do
assert live_js =~ "this.syncExpandedSurfaces();"
end
test "chat editor restores dismissed surfaces from persisted surface state when reopening a chat" do
assert {:ok, conversation} = AI.start_chat(%{title: "Reopen Chat", model: "gpt-4.1"})
now = Persistence.now_ms()
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :user,
content: "Show me two cards",
created_at: now
})
)
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :assistant,
content: "Here are two cards.",
tool_calls:
Jason.encode!([
%{
"id" => "call-card-a",
"name" => "render_card",
"arguments" => %{
"title" => "UniqueTitleAlpha",
"body" => "First card alpha"
}
},
%{
"id" => "call-card-b",
"name" => "render_card",
"arguments" => %{
"title" => "UniqueTitleBeta",
"body" => "Second card beta"
}
}
]),
created_at: now + 1
})
)
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "chat",
"id" => conversation.id,
"title" => conversation.title,
"subtitle" => conversation.model || "chat"
})
assert length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 2
surface_id_a = Regex.run(~r/id="([^"]+-surface-0)"/, html) |> Enum.at(1)
dismissed_html =
view
|> element("button[phx-value-surface-id='#{surface_id_a}']")
|> render_click()
assert length(:binary.matches(dismissed_html, ~s(data-testid="chat-inline-surface"))) == 1
persisted = AI.get_surface_state(conversation.id)
assert MapSet.new(persisted["dismissed_surfaces"]) == MapSet.new([surface_id_a])
{:ok, view2, _html2} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html2 =
render_click(view2, "pin_sidebar_item", %{
"route" => "chat",
"id" => conversation.id,
"title" => conversation.title,
"subtitle" => conversation.model || "chat"
})
assert length(:binary.matches(html2, ~s(data-testid="chat-inline-surface"))) == 1
assert html2 =~ "UniqueTitleBeta"
refute html2 =~ ~r/id="#{Regex.escape(surface_id_a)}"/
end
test "chat editor folds tool-only assistant steps into the final assistant answer" do
assert {:ok, conversation} = AI.start_chat(%{title: "Tool Chat", model: "gpt-4.1"})