Compare commits

..

33 Commits

Author SHA1 Message Date
d03d033548 fix: fixed shutdown race 2026-05-29 16:16:33 +02:00
74ceaeb971 fix: force full re-embed on explicit rebuild and degrade gracefully when embedding model is unavailable 2026-05-29 15:49:53 +02:00
61ff2a77c0 perf: A1-14b replace O(n^2) embedding snapshot with hnswlib HNSW index and debounced persistence 2026-05-29 15:36:13 +02:00
744f7543d7 perf: batch CPU embedding inference and add A1-14c Apple GPU (EMLX) spec gap 2026-05-29 14:43:39 +02:00
a1004d72bf fix: A1-14 real neural embeddings via Bumblebee multilingual-e5-small with Float32 BLOB vector cache 2026-05-29 14:04:51 +02:00
489d787306 fix: A1-13 wire git sidebar to BDS.Git with branch, changes, history, and actions 2026-05-29 13:25:32 +02:00
babae1838d fix: A1-12 functional client-side search with real PagefindUI and fragment index 2026-05-29 10:29:42 +02:00
5b619f492a fix: A1-11 graceful preview shutdown drains inflight requests before stopping 2026-05-29 09:49:54 +02:00
b3434b3054 fix: A1-10 write template file to disk on create instead of leaving file_path empty 2026-05-29 09:43:18 +02:00
5b21dcb17d fix: A1-9 replace native color input with 17-preset colour picker popover + custom hex 2026-05-29 09:28:57 +02:00
1f645f6e5e fix: stabilize atom-leak test by checking specific keys instead of global atom count 2026-05-29 09:19:13 +02:00
99d36e6e2f fix: A1-8 add Liquid/Lua validation gates before template and script publish 2026-05-29 09:16:07 +02:00
d7e30b94cb chore: cleanup of tmp files for test 2026-05-28 22:48:48 +02:00
f1265ee326 fix: broken CSS for metadata diff 2026-05-28 22:41:57 +02:00
c5e09e7316 fix: A1-7 implement 4-level template lookup cascade (post→tag→category→default) 2026-05-28 22:38:35 +02:00
1ae6152da7 fix: A1-15 add PreviewDraftOverlay and GenerationPublishedOnly invariants to specs 2026-05-28 22:27:08 +02:00
0305d80051 fix: A1-6 preview prefers draft content over published files for on-demand rendering 2026-05-28 22:24:07 +02:00
a021fc45cd chore: ignore tmp/ folder 2026-05-28 22:21:47 +02:00
fceb995c7c chore: update allium spec for clearer wording towards embedding model 2026-05-28 22:21:36 +02:00
e58d68e73e fix: A1-6 implement on-demand rendering in preview server per spec 2026-05-28 22:20:39 +02:00
0f30221907 fix: A1-5 implement post editor auto-save after 3000ms idle, on tab switch, and on unmount 2026-05-28 21:45:42 +02:00
d423b6db98 chore: claude md and gitignore for tmp 2026-05-28 21:41:37 +02:00
3adb4407a0 chore: removed tmp/ 2026-05-28 21:41:21 +02:00
05923f255b fix: A1-4 omit doNotTranslate from frontmatter when false per spec 2026-05-28 21:36:10 +02:00
ff89d78ab4 fix: A1-3 publish_post deletes old file when post path changes 2026-05-28 21:15:58 +02:00
e2c92cb90d fix: A1-2 delete translation files from disk when parent post is deleted 2026-05-28 18:47:27 +02:00
82ce445c44 fix: A1-1 implement archived→draft/published transitions, wire archive/unarchive into post editor quick actions, complete all i18n translations 2026-05-28 18:39:52 +02:00
f99e139fa5 feat: added a gallery quick action and fleshed out builtin macros 2026-05-28 17:19:49 +02:00
1914b05f39 chore: tend to allium spec to align with code 2026-05-28 13:36:55 +02:00
b09b14cc03 fix: fixes to rendering in the AI chat 2026-05-28 11:21:03 +02:00
721b1ae626 fix: styling for a2ui surfaces was missing 2026-05-28 11:03:22 +02:00
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
131 changed files with 15226 additions and 6793 deletions

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "phoenix",
"runtimeExecutable": "mix",
"runtimeArgs": ["phx.server"],
"port": 4000
}
]
}

View File

@@ -7,7 +7,16 @@
"Bash(mix ecto.migrate)", "Bash(mix ecto.migrate)",
"Bash(git add *)", "Bash(git add *)",
"Bash(git push *)", "Bash(git push *)",
"Bash(git -C /Users/gb/Projects/bDS2 status)" "Bash(git -C /Users/gb/Projects/bDS2 status)",
"Bash(git status *)",
"Bash(mix assets.deploy)",
"Bash(mix phx.server)",
"mcp__Claude_Preview__preview_start",
"mcp__Claude_in_Chrome__navigate",
"mcp__Claude_in_Chrome__computer",
"mcp__Claude_in_Chrome__browser_batch",
"mcp__Claude_in_Chrome__javascript_tool",
"Bash(allium check *)"
] ]
} }
} }

3
.gitignore vendored
View File

@@ -3,10 +3,11 @@
/deps/ /deps/
/dist/ /dist/
/doc/ /doc/
/tmp/
/.elixir_ls/ /.elixir_ls/
/erl_crash.dump /erl_crash.dump
/node_modules/ /node_modules/
/priv/data/*.db /priv/data/*.db
/priv/data/*.db-shm /priv/data/*.db-shm
/priv/data/*.db-wal /priv/data/*.db-wal
*.ez *.eztmp/

View File

@@ -113,6 +113,7 @@ This document provides context and best practices for GitHub Copilot when workin
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback - For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
- English fallback is allowed only when the requested locale is unsupported by available locale files - English fallback is allowed only when the requested locale is unsupported by available locale files
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras - The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
- When adding new `msgid` entries, you MUST provide translations for ALL supported locales (de, fr, it, es) — empty `msgstr` values are not acceptable
> **No hardcoded user-facing text. No exceptions.** > **No hardcoded user-facing text. No exceptions.**

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -1,574 +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~~ ✅ FIXED
- **Fixed:** 2026-05-11
- **What was done:**
- Replaced `if result == :ok and ...` in `rewrite_template_file/2` with a `case` expression using pattern matching and a `when` guard.
- `Persistence.atomic_write` result is now matched directly: `:ok when file_path changed` triggers old file cleanup, `other` (including `{:error, _}`) is returned as-is.
---
### ~~CSM-028 — Broad `rescue` Swallowing Template Errors~~ ✅ FIXED
- **Fixed:** 2026-05-11
- **What was done:**
- Replaced `Liquex.FileSystem.read_template_file` (which raises on missing templates) with `BDS.Rendering.FileSystem.try_read` (returns `{:ok, source}` / `{:error, :enoent}`).
- Missing template now logs a warning and returns `""` without raising.
- Extracted `render_macro_source/4` to separate file reading from template parsing/rendering.
- `Liquex.render!` rescue remains specific to `Liquex.Error` (no non-bang variant exists in Liquex).
- No broad `rescue _error ->` or `rescue _ ->` clauses remain in `filters.ex`.
- Added 3 source-level tests in `test/bds/csm028_broad_rescue_test.exs`: no broad rescue clauses, no `read_template_file` usage, `try_read` is used instead.
---
### ~~CSM-029 — `length/1` in Guards or Comparisons~~ ✅ FIXED
- **Fixed:** 2026-05-11
- **What was done:**
- **`lib/bds/generation/outputs.ex`** — `category_route_paths`, `tag_route_paths`, `date_route_paths`:
- Bound `post_count = length(posts)` at the start of each `Enum.flat_map` callback, before passing to `paginated_archive_paths`. Eliminates inline `length/1` calls inside loop bodies.
- **`lib/bds/ui/sidebar.ex`** — `build_post_section`:
- Bound `post_count = length(posts)` before the map literal instead of computing `length(posts)` inline in the `count:` field.
- Added 3 source-level tests in `test/bds/csm029_length_in_guards_test.exs`: no inline `length(posts)` in `paginated_archive_paths` calls, no inline `length()` in `build_post_section` map literal, no inline `length(posts)` in route path function callbacks.
---
### ~~CSM-030 — Unchecked `File.mkdir_p` / `File.mkdir_p!`~~ ✅ FIXED
- **Fixed:** 2026-05-11
- **What was done:**
- **`lib/bds/media/thumbnails.ex`** — Already fixed in CSM-009; `File.mkdir_p` is inside a `with` chain in `write_all_thumbnails`.
- **`lib/bds/media/sidecars.ex`** — Removed redundant `File.mkdir_p` calls from `write_sidecar/2` and `write_translation_sidecar/3` (the underlying `Persistence.atomic_write` already handles `mkdir_p`). Updated specs to return `:ok | {:error, File.posix()}`. Updated callers (`sync_media_sidecar`, `sync_media_translation_sidecar`) to propagate errors.
- **`lib/bds/media.ex`** — Replaced all `:ok = write_sidecar(...)` and `:ok = write_translation_sidecar(...)` match assertions with `log_sidecar_error/2` (mirrors existing `log_thumbnail_error/2` pattern). Sidecar write failures are logged as warnings but don't fail the DB operation.
- **`lib/bds/media/linking.ex`** — Same `log_sidecar_error/2` pattern for post link/unlink sidecar writes.
- **`lib/bds/release_packaging.ex`** — Replaced `File.mkdir_p!` with `File.mkdir_p` in `reset_output/1` (return value propagated through `with` chain in `package/1`). Replaced `File.mkdir_p!` with `with :ok <- File.mkdir_p(...)` in `copy_release/2`. Replaced `File.write!` with `File.write` in `write_manifest/1`.
- Added 6 tests in `test/bds/csm030_unchecked_mkdir_test.exs`: source-level assertions for no unchecked `File.mkdir_p`, no bang variants, no `:ok =` match assertions on sidecar writes.
---
### ~~CSM-031 — `try/rescue` Instead of `with` and Error Tuples~~ ✅ FIXED
- **Fixed:** 2026-05-27
- **What was done:**
- **`lib/bds/rendering/filters.ex`** — Extracted `safe_liquex_render/3` private helper that isolates the unavoidable `Liquex.render!` rescue into a single function returning `{:ok, binary} | {:error, String.t()}`. Replaced inline `try/rescue` in `render_macro_source/4` with a `with` chain using the helper.
- **`lib/bds/rendering/template_selection.ex`** — Same pattern: extracted `safe_liquex_render/3` helper, replaced inline `try/rescue` in `render_template/3` with a `with` chain.
- **`lib/bds/rendering/template_selection.ex`** — `load_bundled_template_source/3`: Replaced raising `Liquex.FileSystem.read_template_file` with `FileSystem.try_read` (returns `{:ok, source} | {:error, :enoent}`), eliminating the function-level `rescue` block entirely. Uses a `with` chain for control flow.
- **`lib/bds/desktop/shell_data.ex`** — Already fixed by CSM-010; no `try/rescue` blocks remain.
- Added 7 tests in `test/bds/csm031_try_rescue_test.exs`: source-level assertions that no inline `try/rescue` around `Liquex.render!` exists in either file, both files define `safe_liquex_render` helpers, `load_bundled_template_source` has no rescue block and uses `FileSystem.try_read`, and `shell_data.ex` has no try/rescue.
---
### ~~CSM-032 — `Map.get` with Default Instead of Pattern Matching~~ ✅ FIXED
- **Fixed:** 2026-05-27
- **What was done:**
- **Metadata struct access** — Replaced `Map.get(metadata, :key)` / `Map.get(metadata, :key, default)` with dot access (`metadata.key`) across 10 files that consume the well-defined metadata map from `Metadata.load_state()`:
- `lib/bds/posts/translation_validation.ex``:main_language`, `:blog_languages`
- `lib/bds/posts/auto_translation.ex``:main_language`, `:blog_languages`
- `lib/bds/desktop/shell_commands.ex``:main_language`, `:blog_languages`
- `lib/bds/desktop/shell_live/settings_editor/project_settings.ex` — all 10 metadata fields
- `lib/bds/desktop/shell_live/settings_editor/style_editor.ex``:pico_theme`
- `lib/bds/desktop/shell_live/settings_editor/managed_categories.ex``:categories`, `:category_settings`
- `lib/bds/desktop/shell_live/settings_editor/publishing_settings.ex``:publishing_preferences`
- `lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex``:categories`
- `lib/bds/desktop/shell_live/import_editor/analysis_state.ex``:default_author`
- `lib/bds/import_execution.ex``:default_author`
- **Overlay context maps** — Replaced `Map.get(delete_details, :key, default)` and `Map.get(merge, :key, default)` with pattern matching in `lib/bds/desktop/overlay.ex`:
- `open(:media, :confirm_delete, ...)` — pattern matches `%{title:, entity_name:, entity_type:, reference_list:}` from `context.delete_details`
- `open(:tags, :confirm_merge, ...)` — pattern matches `%{title:, message:}` from `context.merge_details`
- `normalize_ai_fields/1` — pattern matches `%{key:, label:, current_value:, suggested_value:, locked:}` from each field
- `language_picker/2` — extracts `existing_translations`, `language_names`, `language_flags` into local bindings before the loop, eliminating nested `Map.get(Map.get(...))` calls
- **Overlay media/post maps** — Replaced `Map.get(media, :field)` with dot access in `to_insert_media_result`, `to_gallery_image`, `gallery_images`, search filtering, and `to_insert_link_result` (media/post maps are built with known keys by `overlay_components.ex`)
- **Generation pipeline** — Replaced `Map.get(post, :field)` with `post.field` for Post struct fields across:
- `lib/bds/generation/data.ex``:author`, `:tags`, `:categories`, `:template_slug`, `:do_not_translate`, `:language` in `build_published_translation_variant` and `resolve_posts_for_language`
- `lib/bds/generation/outputs.ex``:language` (all occurrences)
- `lib/bds/generation/validation.ex``:file_path`
- `lib/bds/generation/sitemap.ex``:do_not_translate`
- `lib/bds/preview.ex``:template_slug`
- Kept `Map.get` where appropriate: dynamic field access (`Map.get(post, field)` with variable keys), truly optional keys (`:translation_source_slug` on mixed struct/map lists), external data lookups (string-keyed JSON, form params), and hash-map lookups with defaults.
- Updated `test/bds/desktop/overlay_test.exs` to provide complete `delete_details` and `merge_details` maps matching the real contract from `overlay_components.ex`.
- Added 18 tests in `test/bds/csm032_map_get_pattern_match_test.exs`: source-level assertions that metadata consumers use dot access, overlay uses pattern matching for known structures, and generation pipeline uses dot access for Post struct fields.
---
### ~~CSM-033 — `Enum.each` with Side Effects That Should Be Batch Inserts~~ ✅ FIXED
- **Fixed:** 2026-05-27
- **What was done:**
- **`lib/bds/search.ex`** — Already addressed by CSM-006; `batch_insert_post_index` and `batch_insert_media_index` use multi-row SQL INSERT with chunking.
- **`lib/bds/embeddings.ex`** — Replaced `Enum.each` + per-post `sync_post_if_enabled` (which did N individual `Repo.get_by` reads + N individual `Repo.insert_or_update` writes) in three bulk functions:
- `rebuild_project/2` — preloads all keys via `preload_keys_by_post_id/1`, computes rows with `compute_key_data/3`, batch-upserts with `batch_upsert_keys/1`.
- `repair_posts/2` — same pattern with `preload_keys_by_post_id/2` scoped to target post IDs.
- `index_unindexed/1` — same pattern, eliminating per-post `Repo.get_by` lookups.
- Added `preload_keys_by_post_id/1` and `/2` — single-query key preload into a map by post_id.
- Added `max_label_value/0` — reads max label once instead of per-post `next_label()` queries.
- Added `compute_key_data/3` — resolves body, hashes, embeds (if needed), returns `:skip` or `{:upsert, row}`.
- Added `batch_upsert_keys/1` — multi-row `INSERT INTO embedding_keys ... ON CONFLICT(label) DO UPDATE` with 199-row chunking (SQLite 999-param limit ÷ 5 columns).
- `sync_post_if_enabled/2` retained for single-post `sync_post/1` path (CRUD operations).
- Added 11 tests in `test/bds/csm033_batch_inserts_test.exs`: source-level assertions (no Enum.each+sync_post_if_enabled, batch_upsert_keys present, preload present, ON CONFLICT upsert, compute_key_data used), search.ex batch verification, and functional tests (index 5 posts, rebuild updates stale keys, repair targets subset, skip on matching hash).
---
### ~~CSM-034 — `File.read!` / `File.write!` Without Error Handling~~ ✅ FIXED
- **Fixed:** 2026-05-27
- **What was done:**
- **`lib/bds/preview_assets.ex`** — `generated_outputs/0`: Replaced `File.read!` with `File.read` inside `Enum.flat_map`, silently skipping files that become unreadable between `Path.wildcard` and read (TOCTOU race).
- **`lib/bds/templates.ex`** — `upsert_template_from_file/3`: Replaced `File.read!` and pattern-matched `Frontmatter.parse_document` with a `with` chain returning `{:ok, template} | {:error, reason}`. Replaced `Repo.insert_or_update!` with `Repo.insert_or_update` to propagate changeset errors.
- **`lib/bds/templates.ex`** — Updated all three callers: `rebuild_templates_from_files` logs a warning and skips bad files, `sync_template_from_file` and `import_orphan_template_file` map errors to `{:error, :not_found}`.
- **`lib/bds/release_packaging.ex`** — Already fixed by CSM-030 (`File.write!``File.write`).
- Added 8 tests in `test/bds/csm034_file_read_bang_test.exs`: source-level assertions for no bang file ops in all three files, functional tests for rebuild skipping bad templates, sync returning error on deleted file, import returning error on missing file, and preview_assets returning valid output tuples.
---
### ~~CSM-035 — Process Dictionary (`Process.get/put`) Usage~~ ✅ FIXED
- **Fixed:** 2026-05-27
- **What was done:**
- **`lib/bds/desktop/ui_locale.ex`** — Added explicit **Invariant** section to `@moduledoc` documenting that every code path evaluating HEEx templates with `translated/1,2` must call `UILocale.put/1` before template evaluation. Lists all three render boundaries: `ShellLive.render/1`, `SidebarComponents.sidebar_content/1`, and `MenuBar.mount/1` + `handle_info({:set_ui_locale, _})`.
- Verified no raw `Process.put(:bds_ui_locale, ...)` or `Process.get(:bds_ui_locale)` exists outside `ui_locale.ex`.
- Added 9 tests in `test/bds/csm035_process_dict_test.exs`: source-level assertions that no raw Process.put/get/delete for `:bds_ui_locale` exists outside the module, render boundary assertions that `ShellLive.render/1`, `sidebar_content/1`, and `MenuBar.mount/1` call `UILocale.put` before template evaluation, and functional tests for `put/1`, `current/0`, and `with_locale/2` (including nil-restore behavior).
---
### ~~CSM-036 — Missing `@impl true` on GenServer Callbacks~~ ✅ FIXED
- **Fixed:** 2026-05-27
- **What was done:**
- Added `@impl true` before all four `handle_call` clauses that were missing it in `lib/bds/publishing.ex`: `{:update_job, ...}`, `{:should_upload_scp_file, ...}`, `{:mark_uploaded_scp_file, ...}`, and `{:upload_site, ...}`.
- No `handle_cast`, `handle_info`, or `terminate` callbacks exist in this module; only `handle_call` needed fixing.
- Added 2 tests in `test/bds/csm036_impl_true_test.exs`: source-level assertion that every `handle_call` clause is preceded by `@impl true`, and a guard test for any future `handle_cast`/`handle_info`/`terminate` callbacks.
---
## 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,40 +10,45 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| ID | Gap | Spec | Code | Path | | 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: implement unarchive transitions | | A1-1 | ~~No `archived→draft` or `archived→published` transition~~ | post.allium:121-122 | `unarchive_post/1` implemented, `publish_post` already handled archived→published | **Resolved:** `unarchive_post/1` in posts.ex restores content from disk, UI wired via quick actions, 4 tests added |
| 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-2 | ~~`DeletePost` must delete translations + translation files~~ | post.allium:209-212 | `delete_post/1` now fetches translations before cascade-delete and removes their files from disk | **Resolved:** translation file cleanup added to `delete_post/1` in posts.ex, test added |
| 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-3 | ~~Publish must delete old file when path changes~~ | engine_side_effects.allium:73-74 | `publish_post` now deletes old file when `file_path` changes | **Resolved:** old file deletion added to `publish_post/1` in posts.ex, test added |
| 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-4 | ~~`doNotTranslate: false` written to frontmatter despite "only when true"~~ | frontmatter.allium:398 | `file_sync.ex:78` now converts false→nil so serializer omits the key | **Resolved:** doNotTranslate omitted from frontmatter when false, test added |
| 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-5 | ~~Auto-save after 3000ms idle~~ | editor_post.allium:183-188 | PostEditor schedules auto-save via parent timer on dirty change | **Resolved:** 3000ms idle auto-save timer in Bridges, tab-switch save in ShellLive, cancel on manual save, 3 tests added |
| 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-6 | ~~On-demand rendering in preview server~~ | preview.allium:53-93 | `Preview.Router` matches post/archive/home/language routes and renders on-demand via `Rendering` | **Resolved:** `Preview.Router` implements on-demand template rendering for post, archive, home, date, tag, category, page, and language-prefixed routes; static file fallback retained for non-HTML assets (pagefind, feeds); 6 tests added |
| 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-7 | ~~Template lookup must use all 4 levels (post→tag→category→default)~~ | template_context.allium:267-277 | `resolve_post_template_slug/3` implements tagcategory cascade; all callers (preview, generation) updated | **Resolved:** `resolve_post_template_slug/3` in template_selection.ex, callers in preview.ex, router.ex, outputs.ex updated, 8 tests added |
| 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-8 | ~~`ValidateLiquid`/`ValidateScript` before publish~~ | template.allium:110, script.allium:165 | `publish_template` validates Liquid via `Liquex.parse`, `publish_script` validates Lua via `BDS.Scripting.validate` | **Resolved:** validation gates added to `publish_template/1` and `publish_script/1`, invalid content returns `{:error, {:invalid_liquid|:invalid_script, reason}}`, 4 tests added |
| 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-9 | ~~17 preset colors + custom hex in tag picker~~ | editor_tags.allium | `ColourPicker` hook + popover with 17 preset swatches grid and custom hex input, wired to both create and edit forms | **Resolved:** replaced native `<input type="color">` with `ColourPickerPopover` component (17 presets, custom hex #RRGGBB, immediate selection), JS hook for click-away dismiss, 1 test added |
| 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-10 | ~~Template file written on create~~ | engine_side_effects.allium:151-153 | `create_template` now computes `file_path` and writes template file with YAML frontmatter on create | **Resolved:** `create_template/1` writes `templates/{slug}.liquid` on create, `next_template_file_path` always computes path, 1 test added |
| 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-11 | ~~Graceful shutdown with inflight request tracking~~ | preview.allium:47-48 | `stop_preview` now closes the listener, parks the reply, and drains monitored inflight request tasks before reporting stopped | **Resolved:** acceptor transfers socket ownership to each request task; GenServer monitors inflight tasks, `begin_graceful_stop` stops accepting and finalizes via `:DOWN`/`:drain_timeout` (5s force-kill cap), 1 test added |
| 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 | | A1-12 | ~~Real Pagefind integration for search~~ | generation.allium:208 | Functional client-side search: `PagefindUI` defined in bundled `pagefind-ui.js`, fragment index records url/title/body-scoped text per page, search-runtime wires it up | **Resolved:** bundled real `PagefindUI` (fetch index, ranked full-text match, highlighted excerpts) + `pagefind-ui.css` as local assets read into `Pagefind`; index scoped to `data-pagefind-body` (unmarked pages excluded per PagefindHtmlMarking), title from `<title>`/`<h1>`; localized "No results found" label via `data-search-no-results` (de/fr/it/es); 3 unit tests added |
| A1-13 | ~~Git sidebar shows only "Working tree" placeholder~~ | sidebar_views.allium:651-770 | `git_view/1` now builds a full `layout: "git"` view from `BDS.Git` (repository/remote_state/status/history); `SidebarComponents` renders active + not_a_repo states | **Resolved:** `git_view/1` in sidebar.ex assembles branch/upstream/ahead/behind, status files, paginated history (20/page); `render_git_sidebar` renders branch header, sync legend, fetch/pull/push/prune-lfs buttons, commit form, clickable status files (open git_diff), history entries; shell_live wires `git_commit` (closes git_diff tabs), `git_fetch`/`git_pull`/`git_push`/`git_prune_lfs`, `git_initialize`; `BDS.Git.history` enriched with author/date, `BDS.Git.set_remote/2` added; i18n for de/fr/it/es; 3 shell tests + git author/date assertions added |
| A1-14 | ~~Embedding uses TF-IDF hash projection instead of real neural model~~ | embedding.allium:44-53, invariants RealNeuralModel/ModelCaching/VectorCacheInDb | `Backends.Neural` runs `intfloat/multilingual-e5-small` (e5 weights behind the Xenova id) via Bumblebee+EXLA | **Resolved (core):** added bumblebee/nx/exla deps; `Backends.Neural` is a lazily-loaded GenServer that builds the Bumblebee text-embedding serving on first request (`"query: "` prefix + mean pooling + L2 norm), downloads+caches the model under the app data dir (ModelCaching), and is wired into the supervision tree when configured; vectors now persisted as packed little-endian Float32 BLOB (384×4=1536 bytes) instead of JSON text (VectorCacheInDb) with migration recreating `embedding_keys.vector` as BLOB; `InApp` demoted to documented offline/test stub; test config uses the stub so the suite stays offline; spec EmbeddingModel clarified (Xenova id ↔ intfloat weights via Bumblebee); batched inference via optional `embed_many/2` backend callback (configurable `batch_size`/`sequence_length`; rebuild/index/repair embed in chunks instead of one post at a time) + `NativeAcceleratedExecution` invariant added to spec; 4 tests added (BLOB round-trip, batched-rebuild, Neural model_info/behaviour). **Deferred:** A1-14b USearch HNSW index, A1-14c Apple GPU (EMLX). |
| A1-14b | ~~USearch HNSW ANN index + debounced persistence not implemented~~ | embedding.allium config/FindSimilar/DebouncedPersistence | `Embeddings.Index` is now an HNSW (hnswlib) ANN index with debounced persistence | **Resolved:** rewrote `Embeddings.Index` as a DB-free GenServer wrapping an hnswlib HNSW graph (cosine, M=16, efConstruction=128, efSearch=64) — O(n·log n) build, O(log n) queries, replacing the O(n²) JSON cosine snapshot; per-project in-memory index + `label→post_id` map; 5s debounced `save_index` + `.meta.json` sidecar, force-save on project switch (`set_active_project`) and shutdown (`terminate`), `forget/1` on project delete; lazy reload from disk with rebuild-from-DB self-heal on miss; `find_similar`/`find_duplicates`/`compute_similarities` rewired (no brute-force fallback); USearch has no Elixir binding so hnswlib provides the identical HNSW algorithm/params (spec reconciled); supervision + dialyzer PLT updated; tests updated for debounced/binary persistence + self-heal. Follow-up hardening: explicit rebuild now forces re-embedding regardless of content_hash (ReindexAll), and model-unavailable errors propagate cleanly (post saves degrade to unindexed + log; rebuild/index return `{:error, reason}` surfaced as a failed task with a user-facing message instead of crashing). |
| A1-14c | Embedding model runs on CPU only; no Apple GPU acceleration | embedding.allium invariant NativeAcceleratedExecution | `Backends.Neural` uses Bumblebee+EXLA; on Apple Silicon XLA has no Metal backend so inference is native CPU (batched). Apple GPU/Neural Engine unused | Fix code: spike an EMLX (Apple MLX) Nx backend so the model executes on the Apple Silicon GPU; gate by platform/availability with EXLA-CPU fallback; verify Bumblebee serving + defn compiler compatibility and benchmark vs CPU batching |
| A1-15 | ~~Preview vs generation content source strategy undocumented~~ | preview.allium (no invariant), generation.allium (no invariant) | Generation uses only published .md file content (`Generation.Data` snapshots set `content: nil`); preview includes published+draft posts and prefers DB content over file (`Preview.Router` queries `:published`/`:draft`, uses `editor_body`) | **Resolved:** added `PreviewDraftOverlay` invariant to preview.allium and `GenerationPublishedOnly` invariant to generation.allium; both cross-reference each other; code already correct, 3 tests added for draft-in-preview behavior |
### A2. Spec Should Update (code is normative) ### A2. Spec Should Update (code is normative)
| ID | Gap | Spec | Code | Path | | 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-1 | ~~WYSIWYG/visual editor mode (3 modes)~~ | editor_post.allium:159-164 | Only markdown+preview; visual normalizes to markdown | **Resolved:** spec updated to 2 modes (markdown/preview), visual/WYSIWYG dropped |
| 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-2 | ~~Template/Script are global entities~~ | template.allium, script.allium | Both have `project_id`, per-project uniqueness | **Resolved:** spec updated — added `project_id` to entities, scoped uniqueness invariants and create rules per project |
| A2-3 | TagsFile uses `{tags: [...]}` wrapper | frontmatter.allium:255-273 | Code writes bare array `[...]` | Update spec | | A2-3 | ~~TagsFile uses `{tags: [...]}` wrapper~~ | frontmatter.allium:255-273 | Code writes bare array `[...]` | **Resolved:** spec updated — removed wrapper object, TagEntry is now the top-level value, bare array in invariant, camelCase keys |
| A2-4 | Sidecar is "YAML-like, not gray-matter" | frontmatter.allium:174 | Code wraps with `---` delimiters | Update spec to gray-matter style | | A2-4 | ~~Sidecar is "YAML-like, not gray-matter"~~ | frontmatter.allium:174 | Code wraps with `---` delimiters | **Resolved:** spec updated — format comment now says gray-matter style with --- delimiters |
| A2-5 | Translation frontmatter omits status/timestamps | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | Update spec to match written fields | | A2-5 | ~~Translation frontmatter omits status/timestamps~~ | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | **Resolved:** spec updated — TranslationFrontmatter now includes status, created_at, updated_at, published_at; TranslationFilesInheritCanonicalMetadata renamed to TranslationFrontmatterRoundtrip; translation.allium invariant updated to TranslationFilesCarryFullMetadata |
| A2-6 | Search index has single `stemmed_content` | search.allium:40-54 | FTS5 per-field stemmed columns | Update spec to per-field model | | A2-6 | ~~Search index has single `stemmed_content`~~ | search.allium:40-54 | FTS5 per-field stemmed columns | **Resolved:** spec updated — PostSearchIndex has title/excerpt/content/tags/categories; MediaSearchIndex has title/alt/caption/original_name/tags; SearchMedia now accepts filters; index rules use delete-and-reinsert with per-field stemming |
| A2-7 | Tag archives are single-page | generation.allium:142-147 | Code paginates | Update spec | | A2-7 | ~~Tag archives are single-page~~ | generation.allium:142-147 | Code paginates | **Resolved:** spec updated — GenerateTagPages now paginated like categories, using max_posts_per_page |
| A2-8 | Date archives year+month only | generation.allium:151-159 | Code also generates day-level | Update spec | | A2-8 | ~~Date archives year+month only~~ | generation.allium:151-159 | Code also generates day-level | **Resolved:** spec updated — GenerateDateArchivePages now includes day-level archives, all three levels paginated |
| A2-9 | Menu is DB entity | menu.allium:20-26 | Purely file-based OPML, no DB table | Update spec to file-only model | | A2-9 | ~~Menu is DB entity~~ | menu.allium:20-26 | Purely file-based OPML, no DB table | **Resolved:** spec updated — `entity Menu` changed to `value Menu`, file-only model with OPML persistence, added LoadMenu/SyncMenuFromFilesystem rules |
| A2-10 | Panel tabs: problems, terminal | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | Update spec | | A2-10 | ~~Panel tabs: problems, terminal~~ | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | **Resolved:** spec already lists tasks/output/post_links/git_log with availability and fallback rules matching code |
| A2-11 | Git sidebar: commit input, history, push/pull | sidebar_views.allium | Only "Working tree" item | Mark as partial/TODO in spec | | A2-11 | ~~Git sidebar: commit input, history, push/pull~~ | sidebar_views.allium | Only "Working tree" item | **Moved to A1-13:** backend code exists in BDS.Git, sidebar must wire it up |
| A2-12 | Slug timestamp fallback after 999 | post.allium:21 | Unbounded numeric suffix | Update spec or fix code | | A2-12 | ~~Slug timestamp fallback after 999~~ | post.allium:21 | Unbounded numeric suffix | **Resolved:** spec updated — uniqueness comment now says unbounded numeric suffix, no 999 cap or timestamp fallback |
| A2-13 | Thumbnail generation is async | engine_side_effects.allium:117 | Synchronous | Update spec or fix code | | A2-13 | ~~Thumbnail generation is async~~ | engine_side_effects.allium:117 | Synchronous | **Resolved:** spec updated — import thumbnail generation now says synchronous (awaited, logged on error), matching code; summary table changed from `async` to `sync` |
| A2-14 | AiModelModality: :video vs :file/:tool | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | Update spec to :file/:tool | | A2-14 | ~~AiModelModality: :video vs :file/:tool~~ | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | **Resolved:** spec updated — modality enum now lists "text" \| "image" \| "audio" \| "file" \| "tool", matching code |
| A2-15 | JSON key convention: snake_case vs camelCase | frontmatter.allium values | Code uses camelCase for all metadata JSON | Update spec to camelCase | | A2-15 | ~~JSON key convention: snake_case vs camelCase~~ | frontmatter.allium values | Code uses camelCase for all metadata JSON | **Resolved:** all value types in frontmatter.allium updated to camelCase field names; added CamelCaseKeys invariant; surfaces updated; also added linkedPostIds to MediaSidecar (C-2) and projectId to TemplateFrontmatter/ScriptFrontmatter (B1-9) |
| 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-16 | ~~Snowball stemmer language list~~ | search.allium:26-31 | Library determines which have algorithms vs passthrough | **Resolved:** spec updated — StemmerLanguage comment now says "Snowball stemmers via library (Stemex); languages with algorithm get real stemming, others pass through" |
| A2-17 | `provider_package_ref` on AiModel | schema.allium:282 | Not in code; legacy field not needed | Drop from spec | | A2-17 | ~~`provider_package_ref` on AiModel~~ | schema.allium:282 | Not in code; legacy field not needed | **Resolved:** dropped from AiModel entity and AiModelRecordSurface in schema.allium; DB column retained (migration artifact) |
--- ---
@@ -60,8 +65,8 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity | | B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity |
| B1-6 | Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering) | `lib/bds/rendering/` | Distill into spec | | B1-6 | Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering) | `lib/bds/rendering/` | Distill into spec |
| B1-7 | 404.html generation | `lib/bds/generation/outputs.ex:344-345` | Add to generation.allium | | B1-7 | 404.html generation | `lib/bds/generation/outputs.ex:344-345` | Add to generation.allium |
| B1-8 | `linkedPostIds` in media sidecar | `lib/bds/media/sidecars.ex:42` | Add to frontmatter.allium MediaSidecar | | B1-8 | ~~`linkedPostIds` in media sidecar~~ | `lib/bds/media/sidecars.ex:42` | **Resolved:** added to MediaSidecar value in frontmatter.allium (with A2-15) |
| B1-9 | `projectId` in template/script frontmatter | `templates.ex:337`, `scripts.ex:268` | Add to frontmatter.allium | | B1-9 | ~~`projectId` in template/script frontmatter~~ | `templates.ex:337`, `scripts.ex:268` | **Resolved:** added projectId to TemplateFrontmatter and ScriptFrontmatter in frontmatter.allium (with A2-15) |
| B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium | | B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium |
| B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium | | B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium |
| B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium | | B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium |
@@ -97,8 +102,8 @@ All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Conflict | Resolution | Path | | ID | Conflict | Resolution | Path |
|---|---|---|---| |---|---|---|---|
| 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-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-2 | ~~media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it~~ | Code writes `linkedPostIds` → add to frontmatter.allium | **Resolved:** linkedPostIds added to MediaSidecar in frontmatter.allium (with A2-15) |
| 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 | | 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 | **Resolved:** both specs updated (see A2-5) |
--- ---
@@ -181,7 +186,7 @@ All reconciled to follow code. Specs must be self-consistent and match code.
## Priority Order for Resolution ## Priority Order for Resolution
1. **A1-1 through A1-12** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown) 1. **A1-1 through A1-14c** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown, real embedding model, HNSW ANN index; only A1-14c = Apple GPU/EMLX acceleration still open)
2. **D1-1 through D1-18** — untested invariants/guarantees 2. **D1-1 through D1-18** — untested invariants/guarantees
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code) 3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
4. **B1-1 through B1-6** — major code behaviors missing from spec 4. **B1-1 through B1-6** — major code behaviors missing from spec

View File

@@ -16,4 +16,5 @@
@import "./menu_editor.css"; @import "./menu_editor.css";
@import "./media_editor.css"; @import "./media_editor.css";
@import "./import_editor.css"; @import "./import_editor.css";
@import "./misc_editor.css";
@import "./utilities.css"; @import "./utilities.css";

View File

@@ -86,10 +86,11 @@
.chat-message { .chat-message {
display: flex; display: flex;
max-width: 100%; max-width: 100%;
margin-bottom: 16px;
} }
.chat-message.user { .chat-message.user {
justify-content: flex-end; flex-direction: row-reverse;
} }
.chat-message-content { .chat-message-content {
@@ -102,10 +103,11 @@
} }
.chat-panel .chat-message.user .chat-message-content { .chat-panel .chat-message.user .chat-message-content {
background: transparent; background: var(--vscode-button-background, var(--accent-color, #007acc));
color: var(--vscode-list-activeSelectionForeground); color: var(--vscode-button-foreground, var(--vscode-list-activeSelectionForeground, #ffffff));
border: 0; border: 1px solid var(--vscode-button-background, var(--accent-color, #007acc));
padding: 6px 12px; border-radius: 6px;
padding: 12px 14px;
line-height: 1.35; line-height: 1.35;
} }
@@ -129,19 +131,346 @@
background: var(--vscode-textCodeBlock-background); background: var(--vscode-textCodeBlock-background);
} }
/* ── Inline surfaces (<details> wrappers) ──────────────────────────── */
.chat-inline-surface {
margin: 10px 0;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-sideBar-background);
overflow: hidden;
}
.chat-inline-surface-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
list-style: none;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-inline-surface-header::-webkit-details-marker {
display: none;
}
.chat-inline-surface-header::marker {
content: "";
}
.chat-inline-surface-icon {
flex: 0 0 auto;
font-size: 14px;
line-height: 1;
opacity: 0.7;
}
.chat-inline-surface-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
color: var(--vscode-editor-foreground);
}
.chat-inline-surface-dismiss {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--vscode-descriptionForeground);
font-size: 16px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.chat-inline-surface:hover .chat-inline-surface-dismiss {
opacity: 1;
}
.chat-inline-surface-dismiss:hover {
background: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-editor-foreground);
}
.chat-inline-surface-body {
padding: 0 12px 12px;
}
.chat-inline-surface-body h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--vscode-editor-foreground);
}
/* ── Chart surface ─────────────────────────────────────────────────── */
.chat-surface-chart-type {
margin: 0 0 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
display: none;
}
.chat-surface-chart-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-surface-chart-row {
display: flex;
flex-direction: column;
gap: 2px;
}
.chat-surface-chart-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 12px;
}
.chat-surface-chart-meta span:first-child {
color: var(--vscode-editor-foreground);
}
.chat-surface-chart-meta span:last-child {
color: var(--vscode-descriptionForeground);
font-variant-numeric: tabular-nums;
}
.chat-surface-chart-bar {
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.chat-surface-chart-bar span {
display: block;
height: 100%;
border-radius: 3px;
background: var(--accent-color);
min-width: 0;
transition: width 0.3s ease;
}
/* ── Card surface ──────────────────────────────────────────────────── */
.chat-surface-card {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-surface-subtitle {
margin: 0;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-surface-body {
margin: 0;
font-size: 13px;
line-height: 1.45;
}
.chat-surface-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.chat-surface-action-button {
padding: 4px 12px;
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
font-size: 12px;
cursor: pointer;
}
.chat-surface-action-button:hover {
background: var(--vscode-list-hoverBackground);
}
/* ── Metric surface ────────────────────────────────────────────────── */
.chat-surface-metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.chat-surface-metric-label {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-surface-metric-value {
font-size: 22px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--vscode-editor-foreground);
}
/* ── List surface ──────────────────────────────────────────────────── */
.chat-surface-list {
margin: 0;
padding: 0 0 0 18px;
font-size: 13px;
line-height: 1.5;
}
/* ── Mindmap surface ───────────────────────────────────────────────── */
.chat-surface-mindmap {
margin: 0;
padding: 0;
list-style: none;
font-size: 13px;
}
.chat-surface-mindmap li {
padding: 4px 0;
border-bottom: 1px solid var(--vscode-panel-border);
}
.chat-surface-mindmap li:last-child {
border-bottom: none;
}
.chat-surface-mindmap strong {
display: block;
color: var(--vscode-editor-foreground);
}
.chat-surface-mindmap-children {
display: block;
font-size: 12px;
color: var(--vscode-descriptionForeground);
padding-left: 12px;
}
/* ── Tabs surface ──────────────────────────────────────────────────── */
.chat-surface-tabs {
display: flex;
flex-direction: column;
}
.chat-surface-tab-list {
display: flex;
gap: 0;
border-bottom: 1px solid var(--vscode-panel-border);
}
.chat-surface-tab-button {
padding: 6px 12px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--vscode-descriptionForeground);
font-size: 12px;
cursor: pointer;
}
.chat-surface-tab-button.active {
color: var(--vscode-editor-foreground);
border-bottom-color: var(--accent-color);
}
.chat-surface-tab-button:hover:not(.active) {
color: var(--vscode-editor-foreground);
}
.chat-surface-tab-panel {
padding: 10px 0 0;
}
/* ── Form surface ──────────────────────────────────────────────────── */
.chat-surface-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-surface-form-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-surface-form-field input,
.chat-surface-form-field textarea,
.chat-surface-form-field select {
padding: 5px 8px;
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font: inherit;
}
.chat-surface-form-field textarea {
min-height: 60px;
resize: vertical;
}
.chat-surface-form-checkbox {
display: flex;
align-items: center;
}
/* ── Text surface ──────────────────────────────────────────────────── */
.chat-surface-text {
font-size: 13px;
line-height: 1.45;
white-space: pre-wrap;
}
/* ── Table surface wrapper ─────────────────────────────────────────── */
.chat-tool-surface-table-wrap {
overflow-x: auto;
}
.chat-panel .chat-input-container { .chat-panel .chat-input-container {
--chat-input-line-height: 20px; --chat-input-line-height: 22px;
--chat-input-min-height: 20px; --chat-input-min-height: 24px;
border-top: 1px solid var(--vscode-panel-border); border-top: 1px solid var(--vscode-panel-border);
padding: 8px 16px; padding: 12px 16px;
background: var(--vscode-sideBar-background); background: var(--vscode-sideBar-background);
} }
.chat-panel .chat-input-wrapper { .chat-panel .chat-input-wrapper {
min-height: 30px; min-height: 40px;
border: 1px solid var(--vscode-input-border); border: 1px solid var(--vscode-input-border);
border-radius: 6px; border-radius: 8px;
padding: 4px 6px; padding: 6px 8px;
background: var(--vscode-input-background); background: var(--vscode-input-background);
} }
@@ -160,11 +489,16 @@
max-height: 160px; max-height: 160px;
resize: vertical; resize: vertical;
border: 0; border: 0;
outline: none;
background: transparent; background: transparent;
color: var(--vscode-input-foreground); color: var(--vscode-input-foreground);
overflow-y: hidden; overflow-y: hidden;
} }
.chat-panel .chat-input:focus {
outline: none;
}
.chat-panel .chat-input::placeholder { .chat-panel .chat-input::placeholder {
color: var(--vscode-input-placeholderForeground); color: var(--vscode-input-placeholderForeground);
} }
@@ -221,3 +555,88 @@
padding: 8px 12px; padding: 8px 12px;
} }
} }
/* Colour picker popover */
.colour-picker-wrap {
position: relative;
display: inline-flex;
}
.colour-picker-trigger {
width: 28px;
height: 28px;
border-radius: 4px;
border: 1px solid var(--vscode-input-border);
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.colour-picker-trigger:hover {
opacity: 0.85;
}
.colour-picker-popover {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 30;
padding: 8px;
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
border-radius: 6px;
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
width: 196px;
}
.colour-picker-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
}
.colour-picker-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: border-color 0.1s;
}
.colour-picker-swatch:hover {
border-color: var(--vscode-focusBorder);
}
.colour-picker-swatch.selected {
border-color: var(--vscode-focusBorder);
box-shadow: 0 0 0 1px var(--vscode-focusBorder);
}
.colour-picker-custom {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--vscode-panel-border);
}
.colour-picker-custom label {
font-size: 11px;
color: var(--vscode-descriptionForeground);
white-space: nowrap;
}
.colour-picker-custom input {
flex: 1;
min-width: 0;
font-size: 12px;
padding: 2px 6px;
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-family: monospace;
}

539
assets/css/misc_editor.css Normal file
View File

@@ -0,0 +1,539 @@
/* ── Misc-editor shell (shared by all misc tabs) ──────────────────────── */
.misc-editor-shell {
background: var(--vscode-editor-background);
}
.misc-editor-header {
padding: 12px 16px 8px;
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-tab-activeBackground);
}
.misc-editor-header h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.misc-editor-header p {
margin: 2px 0 0;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.misc-editor-actions {
flex-shrink: 0;
}
.misc-editor-summary {
padding: 8px 16px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.misc-editor-content {
padding: 16px;
}
/* ── Summary pills ───────────────────────────────────────────────────── */
.misc-summary-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
color: var(--vscode-foreground);
}
.misc-summary-pill span {
color: var(--vscode-descriptionForeground);
}
.misc-summary-pill strong {
font-weight: 600;
}
/* ── Misc card (used by site-validation, empty states) ───────────────── */
.misc-card {
padding: 14px 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.misc-card h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
.misc-card p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.misc-card ul {
margin: 6px 0 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.6;
}
/* ── Misc columns (site-validation 3-column layout) ──────────────────── */
.misc-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
/* ── Misc list (find-duplicates) ─────────────────────────────────────── */
.misc-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.misc-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 4px;
}
.misc-list-item:hover {
background: var(--vscode-list-hoverBackground);
}
.duplicate-pair-row label {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
cursor: pointer;
}
.duplicate-pair-row .linkish {
background: none;
border: none;
color: var(--vscode-textLink-foreground, #3794ff);
cursor: pointer;
padding: 0;
font: inherit;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.14em;
}
.duplicate-pair-row .linkish:hover {
color: var(--vscode-foreground);
}
/* ── Metadata-diff: tab bar ──────────────────────────────────────────── */
.metadata-diff-tool {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-diff-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.metadata-diff-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--vscode-tab-inactiveForeground, var(--vscode-descriptionForeground));
font: inherit;
font-size: 12px;
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.metadata-diff-tab:hover {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
}
.metadata-diff-tab.active {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
border-bottom-color: var(--vscode-focusBorder, #007fd4);
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
background: var(--vscode-activityBarBadge-background, #007acc);
color: var(--vscode-activityBarBadge-foreground, #ffffff);
}
/* ── Metadata-diff: field pills ──────────────────────────────────────── */
.metadata-diff-field-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.metadata-diff-field-pill {
display: flex;
align-items: stretch;
border-radius: 6px;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-input-background);
overflow: hidden;
transition: border-color 0.12s;
}
.metadata-diff-field-pill.active {
border-color: var(--vscode-focusBorder, #007fd4);
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 12%, transparent);
}
.metadata-diff-field-pill-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: none;
background: transparent;
color: var(--vscode-foreground);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.metadata-diff-field-pill-toggle:hover {
background: var(--vscode-list-hoverBackground);
}
.field-pill-label {
font-weight: 500;
}
.field-pill-count {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
}
.metadata-diff-field-pill-actions {
display: flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-left: 1px solid var(--vscode-panel-border);
}
.metadata-diff-action-button {
font-size: 11px !important;
padding: 2px 8px !important;
min-height: 22px !important;
}
/* ── Metadata-diff: results area ─────────────────────────────────────── */
.metadata-diff-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-diff-empty p {
text-align: center;
padding: 20px 0;
}
/* ── Diff item cards (shared by metadata-diff and orphan sections) ──── */
.diff-item-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-item-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
overflow: hidden;
}
.diff-item-card.orphan-file {
border-left: 3px solid var(--vscode-editorWarning-foreground, #cca700);
}
.diff-item-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, transparent);
border-bottom: 1px solid var(--vscode-panel-border);
}
.diff-item-header strong {
font-size: 13px;
font-weight: 600;
color: var(--vscode-foreground);
}
.diff-item-meta {
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-top: 2px;
}
.diff-item-fields {
padding: 8px 14px;
}
.diff-field-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border) 50%, transparent);
}
.diff-field-row:last-child {
border-bottom: none;
}
.diff-field-name {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
padding-top: 3px;
}
.diff-field-values {
display: flex;
flex-direction: column;
gap: 3px;
}
.diff-field-value {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 1.4;
word-break: break-word;
}
.diff-field-value.db-value {
color: var(--vscode-foreground);
}
.diff-field-value.file-value {
color: var(--vscode-foreground);
opacity: 0.82;
}
.diff-source-label {
flex-shrink: 0;
min-width: 28px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: 3px;
}
.db-value .diff-source-label {
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 22%, transparent);
color: var(--vscode-focusBorder, #007fd4);
}
.file-value .diff-source-label {
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 22%, transparent);
color: var(--vscode-testing-iconPassed, #73c991);
}
/* ── Orphan files section ────────────────────────────────────────────── */
.orphan-files-section {
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 35%, transparent);
border-radius: 8px;
padding: 14px 16px;
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 5%, var(--vscode-editor-background));
}
.orphan-files-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.orphan-files-header h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.orphan-files-actions {
display: flex;
align-items: center;
gap: 8px;
}
.orphan-path span {
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
/* ── Translation validation ──────────────────────────────────────────── */
.translation-validation-view {
display: flex;
flex-direction: column;
gap: 16px;
}
.translation-validation-summary {
padding: 10px 14px;
border-radius: 6px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.translation-validation-summary p {
margin: 0;
}
.translation-validation-section h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
.translation-validation-empty {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.translation-validation-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.translation-validation-card {
padding: 10px 14px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.translation-validation-card-db {
border-left: 3px solid var(--vscode-focusBorder, #007fd4);
}
.translation-validation-card-file {
border-left: 3px solid var(--vscode-testing-iconPassed, #73c991);
}
.translation-validation-card-title {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
}
.translation-validation-card-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
margin: 0;
font-size: 12px;
}
.translation-validation-card-meta dt {
color: var(--vscode-descriptionForeground);
font-weight: 500;
}
.translation-validation-card-meta dd {
margin: 0;
}
.translation-validation-actions {
display: flex;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--vscode-panel-border);
}
/* ── Git diff ────────────────────────────────────────────────────────── */
.git-diff-view {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
height: 100%;
}
.git-diff-empty {
color: var(--vscode-descriptionForeground);
text-align: center;
padding: 24px 0;
}
.git-diff-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.git-diff-toolbar label {
font-size: 12px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
flex-shrink: 0;
}
.git-diff-toolbar select {
flex: 1;
min-width: 0;
}
.git-diff-editor {
flex: 1;
min-height: 0;
overflow: hidden;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
}

View File

@@ -36,6 +36,8 @@
.confirm-delete-modal, .confirm-delete-modal,
.confirm-dialog, .confirm-dialog,
.gallery-overlay-content { .gallery-overlay-content {
position: relative;
z-index: 1;
background: #1e1e1e; background: #1e1e1e;
border: 1px solid #3c3c3c; border: 1px solid #3c3c3c;
border-radius: 8px; border-radius: 8px;

View File

@@ -0,0 +1,45 @@
export const ColourPicker = {
mounted() {
this._onClickAway = (e) => {
if (!this.el.contains(e.target)) {
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
}
};
document.addEventListener("mousedown", this._onClickAway);
this._setupCustomInput();
},
updated() {
this._setupCustomInput();
},
destroyed() {
document.removeEventListener("mousedown", this._onClickAway);
},
_setupCustomInput() {
const input = this.el.querySelector(".colour-picker-custom input");
if (!input || input._cpBound) return;
input._cpBound = true;
const pushColor = () => {
let val = input.value.trim();
if (val && !val.startsWith("#")) val = "#" + val;
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
const event = this.el.dataset.pickEvent;
this.pushEventTo(this.el.dataset.target, event, { color: val });
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
}
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
pushColor();
}
});
input.addEventListener("blur", pushColor);
}
};

View File

@@ -2,6 +2,7 @@ import { AppShell } from "./app_shell.js";
import { SidebarInteractions } from "./sidebar_interactions.js"; import { SidebarInteractions } from "./sidebar_interactions.js";
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js"; import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
import { ChatSurface } from "./chat_surface.js"; import { ChatSurface } from "./chat_surface.js";
import { ColourPicker } from "./colour_picker.js";
import { MenuEditorTree } from "./menu_editor_tree.js"; import { MenuEditorTree } from "./menu_editor_tree.js";
import { MonacoEditor } from "./monaco_editor.js"; import { MonacoEditor } from "./monaco_editor.js";
import { MonacoDiffEditor } from "./monaco_diff_editor.js"; import { MonacoDiffEditor } from "./monaco_diff_editor.js";
@@ -12,6 +13,7 @@ export const Hooks = {
SettingsSectionScroll, SettingsSectionScroll,
TagsSectionScroll, TagsSectionScroll,
ChatSurface, ChatSurface,
ColourPicker,
MenuEditorTree, MenuEditorTree,
MonacoEditor, MonacoEditor,
MonacoDiffEditor MonacoDiffEditor

View File

@@ -61,9 +61,18 @@ config :bds, :scripting,
job_max_reductions: :none job_max_reductions: :none
config :bds, :embeddings, config :bds, :embeddings,
backend: BDS.Embeddings.Backends.InApp, backend: BDS.Embeddings.Backends.Neural,
model_id: "Xenova/multilingual-e5-small", model_id: "Xenova/multilingual-e5-small",
dimensions: 384 model_repo: "intfloat/multilingual-e5-small",
dimensions: 384,
# Inference is batched: batch_size texts per compiled run, truncated to
# sequence_length tokens. Tuning these trades throughput against memory.
batch_size: 16,
sequence_length: 256
# Cache downloaded model files under the app data directory so they persist
# across sessions (ModelCaching invariant). Overridden at runtime in prod.
config :bumblebee, :cache_dir, Path.expand("../priv/data/models", __DIR__)
config :logger, :console, config :logger, :console,
format: "$time $metadata[$level] $message\n", format: "$time $metadata[$level] $message\n",

View File

@@ -8,4 +8,9 @@ if config_env() == :prod do
config :bds, BDS.Repo, config :bds, BDS.Repo,
database: database_path, database: database_path,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1") pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1")
# Persist downloaded embedding model files alongside the database data dir.
config :bumblebee, :cache_dir,
System.get_env("BDS_MODEL_CACHE_DIR") ||
Path.join(Path.dirname(Path.expand(database_path)), "models")
end end

View File

@@ -8,3 +8,13 @@ config :bds, BDS.Repo,
busy_timeout: 15_000 busy_timeout: 15_000
config :logger, level: :warning config :logger, level: :warning
# Tests use the deterministic lexical stub backend so the suite stays offline
# and never downloads the ~100 MB neural model.
config :bds, :embeddings,
backend: BDS.Embeddings.Backends.InApp,
model_id: "Xenova/multilingual-e5-small",
model_repo: "intfloat/multilingual-e5-small",
dimensions: 384,
batch_size: 16,
sequence_length: 256

View File

@@ -186,4 +186,12 @@ defmodule BDS.AI do
@spec cancel_chat(String.t()) :: :ok @spec cancel_chat(String.t()) :: :ok
defdelegate cancel_chat(conversation_id), to: Chat 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 end

View File

@@ -62,6 +62,42 @@ defmodule BDS.AI.Chat do
Repo.get(ChatConversation, conversation_id) Repo.get(ChatConversation, conversation_id)
end 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()} @spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
case Repo.get(ChatConversation, 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, title: String.t() | nil,
model: String.t() | nil, model: String.t() | nil,
copilot_session_id: String.t() | nil, copilot_session_id: String.t() | nil,
surface_state: map() | nil,
created_at: integer() | nil, created_at: integer() | nil,
updated_at: integer() | nil updated_at: integer() | nil
} }
@@ -19,13 +20,14 @@ defmodule BDS.AI.ChatConversation do
field :title, :string field :title, :string
field :model, :string field :model, :string
field :copilot_session_id, :string field :copilot_session_id, :string
field :surface_state, :map
field :created_at, :integer field :created_at, :integer
field :updated_at, :integer field :updated_at, :integer
end end
def changeset(conversation, attrs) do def changeset(conversation, attrs) do
conversation 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] empty_values: [nil]
) )
|> validate_required([:id, :title, :created_at, :updated_at]) |> validate_required([:id, :title, :created_at, :updated_at])

View File

@@ -37,14 +37,24 @@ defmodule BDS.Application do
{Task.Supervisor, name: BDS.TCP.TaskSupervisor}, {Task.Supervisor, name: BDS.TCP.TaskSupervisor},
BDS.Scripting.JobStore, BDS.Scripting.JobStore,
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor}, {Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
BDS.Scripting.JobSupervisor BDS.Scripting.JobSupervisor,
| desktop_children(current_env()) BDS.Embeddings.Index
] ] ++ embedding_children() ++ desktop_children(current_env())
opts = [strategy: :one_for_one, name: BDS.Supervisor] opts = [strategy: :one_for_one, name: BDS.Supervisor]
Supervisor.start_link(children, opts) Supervisor.start_link(children, opts)
end end
# The neural embedding backend runs as a supervised, lazily-initialised
# GenServer (it loads the model only on the first embedding request). Only
# start it when it is the configured backend.
defp embedding_children do
case Application.get_env(:bds, :embeddings, [])[:backend] do
BDS.Embeddings.Backends.Neural -> [BDS.Embeddings.Backends.Neural]
_other -> []
end
end
defp current_env do defp current_env do
Application.get_env(:bds, :current_env_override) || @compiled_env Application.get_env(:bds, :current_env_override) || @compiled_env
end end

View File

@@ -12,6 +12,17 @@ defmodule BDS.Desktop.FilePicker do
end end
end end
def choose_files(prompt, opts \\ []) when is_binary(prompt) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
:cancel
else
case :os.type() do
{:unix, :darwin} -> choose_files_macos(prompt, opts)
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
end
end
end
defp choose_file_macos(prompt) do defp choose_file_macos(prompt) do
script = "POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\")" script = "POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\")"
@@ -21,6 +32,50 @@ defmodule BDS.Desktop.FilePicker do
end end
end end
defp choose_files_macos(prompt, opts) do
multiple = Keyword.get(opts, :multiple, false)
image_only = Keyword.get(opts, :image_only, false)
script_parts = ["POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\""]
script_parts =
if image_only do
script_parts ++ [" of type {\"public.image\"}"]
else
script_parts
end
script_parts =
if multiple do
script_parts ++ [" with multiple selections allowed"]
else
script_parts
end
script = Enum.join(script_parts, "") <> ")"
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
{output, 0} -> parse_choose_files_result(String.trim(output), multiple)
{output, _status} -> normalize_picker_failure(output)
end
end
@doc false
def parse_choose_files_result(output, true = _multiple) do
paths =
output
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
{:ok, paths}
end
@doc false
def parse_choose_files_result(output, false = _multiple) do
{:ok, output}
end
defp normalize_picker_failure(output) do defp normalize_picker_failure(output) do
message = String.trim(output) message = String.trim(output)

View File

@@ -35,7 +35,8 @@ defmodule BDS.Desktop.Overlay do
title: Map.get(context, :insert_media_title, "Insert Media"), title: Map.get(context, :insert_media_title, "Insert Media"),
search_query: "", search_query: "",
results: Enum.map(media, &to_insert_media_result/1), results: Enum.map(media, &to_insert_media_result/1),
all_media: media all_media: media,
post_id: current_id(context)
} }
end end

View File

@@ -120,16 +120,7 @@ defmodule BDS.Desktop.ShellCommands do
"rebuild_embedding_index", "rebuild_embedding_index",
"Rebuild Embedding Index", "Rebuild Embedding Index",
"Embeddings", "Embeddings",
fn report -> fn report -> rebuild_embedding_index_work(project, report) end
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
report.(1.0, "Embedding index rebuilt")
%{
project_id: project.id,
rebuilt_post_ids: rebuilt_post_ids,
rebuilt_count: length(rebuilt_post_ids)
}
end
) )
end end
@@ -524,8 +515,14 @@ defmodule BDS.Desktop.ShellCommands do
}, },
%{ %{
name: "Rebuild Embedding Index", name: "Rebuild Embedding Index",
work: fn report -> work: fn report -> rebuild_embedding_index_work(project, report) end
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report) }
]
end
defp rebuild_embedding_index_work(project, report) do
case Embeddings.rebuild_project(project.id, on_progress: report) do
{:ok, rebuilt_post_ids} ->
report.(1.0, "Embedding index rebuilt") report.(1.0, "Embedding index rebuilt")
%{ %{
@@ -533,9 +530,22 @@ defmodule BDS.Desktop.ShellCommands do
rebuilt_post_ids: rebuilt_post_ids, rebuilt_post_ids: rebuilt_post_ids,
rebuilt_count: length(rebuilt_post_ids) rebuilt_count: length(rebuilt_post_ids)
} }
{:error, reason} ->
{:error, embedding_error_message(reason)}
end end
} end
]
defp embedding_error_message(reason) do
detail =
case reason do
message when is_binary(message) -> message
{:embedding_backend_unavailable, _inner} -> "the embedding service did not start"
other -> inspect(other)
end
"Could not build the embedding index: #{detail}. The model is downloaded on first use, " <>
"so check your internet connection — or turn off semantic similarity in Settings."
end end
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok

View File

@@ -5,13 +5,14 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML import Phoenix.HTML
alias BDS.{AI, BoundedAtoms} alias BDS.{AI, BoundedAtoms, Metadata}
alias BDS.CliSync.Watcher alias BDS.CliSync.Watcher
alias BDS.Desktop.{ExternalLinks, FolderPicker, ShellData, UILocale} alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
alias BDS.Desktop.ShellLive.{ alias BDS.Desktop.ShellLive.{
Bridges, Bridges,
ChatEditor, ChatEditor,
GalleryImport,
ImportEditor, ImportEditor,
MediaEditor, MediaEditor,
MenuEditor, MenuEditor,
@@ -83,6 +84,8 @@ defmodule BDS.Desktop.ShellLive do
"load_more_sidebar" "load_more_sidebar"
] ]
@git_action_events ["git_fetch", "git_pull", "git_push", "git_prune_lfs"]
@layout_menu_actions MapSet.new([ @layout_menu_actions MapSet.new([
:toggle_sidebar, :toggle_sidebar,
:toggle_panel, :toggle_panel,
@@ -175,6 +178,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:output_entries, []) |> assign(:output_entries, [])
|> assign(:panel_post_links, %{backlinks: [], outlinks: []}) |> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|> assign(:panel_git_entries, []) |> assign(:panel_git_entries, [])
|> assign(:auto_save_timers, %{})
|> reload_shell(workbench) |> reload_shell(workbench)
|> apply_url_params(params) |> apply_url_params(params)
|> tap(&sync_menu_bar_locale/1)} |> tap(&sync_menu_bar_locale/1)}
@@ -190,7 +194,8 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event("toggle_assistant_sidebar", _params, socket) do def handle_event("toggle_assistant_sidebar", _params, socket) do
{:noreply, refresh_layout(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))} {:noreply,
refresh_layout(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
end end
def handle_event("select_view", %{"view" => view_id}, socket) do def handle_event("select_view", %{"view" => view_id}, socket) do
@@ -235,6 +240,20 @@ defmodule BDS.Desktop.ShellLive do
SidebarEvents.handle(socket, event, params, &refresh_sidebar/2) SidebarEvents.handle(socket, event, params, &refresh_sidebar/2)
end end
def handle_event(event, _params, socket) when event in @git_action_events do
{:noreply, run_git_action(socket, event)}
end
def handle_event("git_commit", params, socket) do
message = params |> get_in(["git", "message"]) |> to_string() |> String.trim()
{:noreply, commit_git(socket, message)}
end
def handle_event("git_initialize", params, socket) do
remote_url = params |> get_in(["git", "remote_url"]) |> normalize_git_remote_url()
{:noreply, initialize_git(socket, remote_url)}
end
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
{:noreply, create_sidebar_item(socket, kind)} {:noreply, create_sidebar_item(socket, kind)}
end end
@@ -251,6 +270,8 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
socket = auto_save_current_post(socket)
workbench = workbench =
Workbench.open_tab( Workbench.open_tab(
socket.assigns.workbench, socket.assigns.workbench,
@@ -269,6 +290,8 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
socket = auto_save_current_post(socket)
type_atom = BoundedAtoms.editor_route(type, :post) type_atom = BoundedAtoms.editor_route(type, :post)
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id) workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id}) tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id})
@@ -399,6 +422,43 @@ defmodule BDS.Desktop.ShellLive do
def handle_event("overlay_lightbox_next", params, socket), def handle_event("overlay_lightbox_next", params, socket),
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks()) do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do
if socket.assigns.offline_mode do
{:noreply,
append_output_entry(
socket,
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)}
else
project_id = socket.assigns.projects.active_project_id
{:ok, metadata} = Metadata.get_project_metadata(project_id)
concurrency_limit = metadata.image_import_concurrency
language = metadata.main_language || "en"
parent = self()
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
case FilePicker.choose_files(dgettext("ui", "Add Gallery Images"),
image_only: true,
multiple: true
) do
{:ok, paths} when is_list(paths) and paths != [] ->
GalleryImport.start(paths, project_id, post_id, language, concurrency_limit, parent)
:cancel ->
send(parent, {:add_images_cancelled})
{:error, reason} ->
send(parent, {:add_images_error, reason})
end
end)
{:noreply, assign(socket, :gallery_import_post_id, post_id)}
end
end
def handle_event("toggle_project_menu", _params, socket) do def handle_event("toggle_project_menu", _params, socket) do
{:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)} {:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)}
end end
@@ -580,6 +640,83 @@ defmodule BDS.Desktop.ShellLive do
OverlayManager.handle_info({:ai_suggestions_error, type, id, reason}, socket) OverlayManager.handle_info({:ai_suggestions_error, type, id, reason}, socket)
end end
def handle_info({:add_image_processed, title}, socket) do
{:noreply,
append_output_entry(
socket,
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Added %{title}", title: title),
nil,
"info"
)}
end
def handle_info({:add_images_complete, count}, socket) do
post_id = socket.assigns[:gallery_import_post_id]
socket =
if is_binary(post_id) do
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :insert_content,
content: "\n[[gallery]]\n"
)
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :refresh
)
socket
|> assign(:gallery_import_post_id, nil)
else
socket
end
{:noreply,
socket
|> append_output_entry(
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Added %{count} images to post", count: count),
nil,
"info"
)}
end
def handle_info({:add_images_error, reason}, socket) do
{:noreply,
append_output_entry(
socket,
dgettext("ui", "Add Gallery Images"),
inspect(reason),
nil,
"error"
)}
end
def handle_info({:add_image_error, path, reason}, socket) do
{:noreply,
append_output_entry(
socket,
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Failed to process %{path}: %{reason}",
path: Path.basename(path),
reason: inspect(reason)
),
nil,
"error"
)}
end
def handle_info({:add_images_cancelled}, socket) do
{:noreply, socket}
end
def handle_info({:test_ping, caller, ref}, socket) do
send(caller, {:test_pong, ref})
{:noreply, socket}
end
def handle_info(message, socket) do def handle_info(message, socket) do
Bridges.handle_info(message, socket, bridges_callbacks()) Bridges.handle_info(message, socket, bridges_callbacks())
end end
@@ -593,13 +730,17 @@ defmodule BDS.Desktop.ShellLive do
defp refresh_layout(socket, workbench) do defp refresh_layout(socket, workbench) do
git_badge_count = socket.assigns[:git_badge_count] || 0 git_badge_count = socket.assigns[:git_badge_count] || 0
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count) activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
task_status = socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
task_status =
socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot() dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language() page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode = Map.get(socket.assigns, :offline_mode, true) offline_mode = Map.get(socket.assigns, :offline_mode, true)
sidebar_data = socket.assigns[:sidebar_data] || %{} sidebar_data = socket.assigns[:sidebar_data] || %{}
current_tab = current_tab(workbench) current_tab = current_tab(workbench)
prev_tab = socket.assigns[:current_tab] prev_tab = socket.assigns[:current_tab]
prev_panel_tab = prev_panel_tab =
case socket.assigns[:workbench] do case socket.assigns[:workbench] do
%Workbench{panel: %{active_tab: tab}} -> tab %Workbench{panel: %{active_tab: tab}} -> tab
@@ -914,6 +1055,122 @@ defmodule BDS.Desktop.ShellLive do
|> push_url_state() |> push_url_state()
end end
defp run_git_action(socket, event) do
project_id = current_project_id(socket)
{label, result} =
case event do
"git_fetch" -> {dgettext("ui", "Fetch"), git_call(project_id, &BDS.Git.fetch/1)}
"git_pull" -> {dgettext("ui", "Pull"), git_call(project_id, &BDS.Git.pull/1)}
"git_push" -> {dgettext("ui", "Push"), git_call(project_id, &BDS.Git.push/1)}
"git_prune_lfs" -> {dgettext("ui", "Prune LFS"), prune_lfs(project_id)}
end
socket
|> append_git_result(label, result)
|> refresh_sidebar(socket.assigns.workbench)
end
defp commit_git(socket, "") do
socket
|> append_output_entry(
dgettext("ui", "Commit"),
dgettext("ui", "Commit message is required"),
nil,
"error"
)
|> refresh_sidebar(socket.assigns.workbench)
end
defp commit_git(socket, message) do
case git_call(current_project_id(socket), &BDS.Git.commit_all(&1, message)) do
{:ok, _result} ->
workbench = close_git_diff_tabs(socket.assigns.workbench)
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> append_output_entry(dgettext("ui", "Commit"), message)
|> refresh_sidebar(workbench)
|> push_url_state()
{:error, reason} ->
socket
|> append_output_entry(dgettext("ui", "Commit"), format_git_error(reason), nil, "error")
|> refresh_sidebar(socket.assigns.workbench)
end
end
defp initialize_git(socket, remote_url) do
project_id = current_project_id(socket)
case git_call(project_id, &BDS.Git.initialize_repo/1) do
{:ok, _repo} ->
_ = maybe_set_git_remote(project_id, remote_url)
socket
|> append_output_entry(
dgettext("ui", "Initialize Git"),
dgettext("ui", "Repository initialized")
)
|> refresh_sidebar(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output_entry(
dgettext("ui", "Initialize Git"),
format_git_error(reason),
nil,
"error"
)
|> refresh_sidebar(socket.assigns.workbench)
end
end
defp git_call(nil, _fun), do: {:error, :no_project}
defp git_call("default", _fun), do: {:error, :no_project}
defp git_call(project_id, fun) when is_binary(project_id), do: fun.(project_id)
defp prune_lfs(nil), do: {:error, :no_project}
defp prune_lfs("default"), do: {:error, :no_project}
defp prune_lfs(project_id) when is_binary(project_id),
do: BDS.Git.prune_lfs_cache(project_id, 10)
defp maybe_set_git_remote(_project_id, nil), do: :ok
defp maybe_set_git_remote(project_id, remote_url),
do: BDS.Git.set_remote(project_id, remote_url)
defp append_git_result(socket, label, {:ok, _result}) do
append_output_entry(socket, label, dgettext("ui", "Done"))
end
defp append_git_result(socket, label, {:error, reason}) do
append_output_entry(socket, label, format_git_error(reason), nil, "error")
end
defp format_git_error(:no_project), do: dgettext("ui", "No active project")
defp format_git_error(%{message: message}) when is_binary(message), do: message
defp format_git_error(%{guidance: guidance}) when is_binary(guidance), do: guidance
defp format_git_error({:git_failed, message}) when is_binary(message), do: message
defp format_git_error(reason), do: inspect(reason)
defp close_git_diff_tabs(workbench) do
workbench.tabs
|> Enum.filter(&(&1.type == :git_diff))
|> Enum.reduce(workbench, fn tab, wb -> Workbench.close_tab(wb, :git_diff, tab.id) end)
end
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
defp normalize_git_remote_url(value) do
case value |> to_string() |> String.trim() do
"" -> nil
url -> url
end
end
defp sidebar_create_action(view), do: SidebarCreate.action(view) defp sidebar_create_action(view), do: SidebarCreate.action(view)
defp set_page_language(socket, language) do defp set_page_language(socket, language) do
@@ -1045,6 +1302,18 @@ defmodule BDS.Desktop.ShellLive do
defp shell_command?(action), do: not is_nil(shell_command_atom(action)) defp shell_command?(action), do: not is_nil(shell_command_atom(action))
defp auto_save_current_post(
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
) do
if Workbench.dirty?(workbench, :post, post_id) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
end
socket
end
defp auto_save_current_post(socket), do: socket
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save) send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
socket socket

View File

@@ -47,6 +47,40 @@ defmodule BDS.Desktop.ShellLive.Bridges do
{:noreply, assign(socket, :workbench, workbench)} {:noreply, assign(socket, :workbench, workbench)}
end end
@default_auto_save_delay 3000
def handle_info({:schedule_auto_save, type, id}, socket, _callbacks) do
timers = socket.assigns[:auto_save_timers] || %{}
key = {type, id}
case Map.get(timers, key) do
nil -> :ok
old_ref -> Process.cancel_timer(old_ref)
end
delay = Application.get_env(:bds, :auto_save_delay, @default_auto_save_delay)
ref = Process.send_after(self(), {:auto_save_fire, type, id}, delay)
{:noreply, assign(socket, :auto_save_timers, Map.put(timers, key, ref))}
end
def handle_info({:cancel_auto_save, type, id}, socket, _callbacks) do
timers = socket.assigns[:auto_save_timers] || %{}
key = {type, id}
case Map.get(timers, key) do
nil -> :ok
old_ref -> Process.cancel_timer(old_ref)
end
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, key))}
end
def handle_info({:auto_save_fire, :post, post_id}, socket, _callbacks) do
timers = socket.assigns[:auto_save_timers] || %{}
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, {:post, post_id}))}
end
def handle_info({:editor_command, action, params}, socket, callbacks) do def handle_info({:editor_command, action, params}, socket, callbacks) do
{:noreply, callbacks.apply_shell_command.(socket, action, params)} {:noreply, callbacks.apply_shell_command.(socket, action, params)}
end end
@@ -116,6 +150,15 @@ defmodule BDS.Desktop.ShellLive.Bridges do
{:noreply, assign(socket, :chat_editor_request_refs, refs)} {:noreply, assign(socket, :chat_editor_request_refs, refs)}
end 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 def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
{:noreply, {:noreply,
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))} callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}

View File

@@ -1,6 +1,8 @@
defmodule BDS.Desktop.ShellLive.ChatEditor do defmodule BDS.Desktop.ShellLive.ChatEditor do
@moduledoc false @moduledoc false
require Logger
use Phoenix.LiveComponent use Phoenix.LiveComponent
import Phoenix.HTML, only: [raw: 1] import Phoenix.HTML, only: [raw: 1]
@@ -37,6 +39,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
{:ok, do_note_streaming_content(socket, content)} {:ok, do_note_streaming_content(socket, content)}
end end
def update(%{action: :persist_surface_state}, socket) do
{:ok, persist_surface_state(socket)}
end
def update(assigns, socket) do def update(assigns, socket) do
socket = socket =
socket socket
@@ -97,7 +103,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
socket socket
) do ) do
next_data = Map.put(socket.assigns.surface_data, surface_id, fields) 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 end
def handle_event( def handle_event(
@@ -111,6 +117,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
:surface_tabs, :surface_tabs,
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index)) Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
) )
|> persist_surface_state()
|> build_data() |> build_data()
{:noreply, socket} {:noreply, socket}
@@ -120,6 +127,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
socket = socket =
socket socket
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id)) |> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|> persist_surface_state()
|> build_data() |> build_data()
{:noreply, socket} {:noreply, socket}
@@ -148,14 +156,29 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp ensure_state(socket) do defp ensure_state(socket) do
conversation_id = socket.assigns.current_tab.id 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 = %{ defaults = %{
conversation_id: conversation_id, conversation_id: conversation_id,
input: "", input: "",
model_selector_open?: false, model_selector_open?: false,
request: nil, request: nil,
surface_data: %{}, surface_data: surface_data,
surface_tabs: %{}, surface_tabs: surface_tabs,
dismissed_surfaces: MapSet.new(), dismissed_surfaces: dismissed_surfaces,
action_error: nil action_error: nil
} }
@@ -819,6 +842,41 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Private helpers ─────────────────────────────────────────────────────── # ── 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 defp active_project_id(socket) do
socket.assigns[:project_id] socket.assigns[:project_id]

View File

@@ -0,0 +1,172 @@
defmodule BDS.Desktop.ShellLive.GalleryImport do
@moduledoc false
require Logger
alias BDS.{AI, Media, Metadata}
@doc """
Starts the image import pipeline: for each selected path, imports the file,
runs AI analysis, updates metadata, links to the post, and translates to
all configured blog languages.
Processes images with a concurrency cap via a sliding window.
"""
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
main_language = metadata.main_language || language
blog_languages = metadata.blog_languages || []
translate_targets =
[main_language | blog_languages]
|> Enum.reject(&(&1 == language or is_nil(&1)))
|> Enum.uniq()
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
tasks =
Enum.map(in_flight, fn path ->
Task.async(fn ->
process_single_image(path, project_id, post_id, language, translate_targets, parent)
end)
end)
known_refs = MapSet.new(tasks, & &1.ref)
drain_tasks(
remaining, tasks, known_refs, project_id, post_id, language, translate_targets, parent
)
send(parent, {:add_images_complete, length(paths)})
end
defp drain_tasks(
[], tasks, _known_refs, _project_id, _post_id, _language, _translate_targets, _parent
) do
Enum.each(tasks, fn task -> Task.await(task, :infinity) end)
end
defp drain_tasks(
[next_path | rest],
tasks,
known_refs,
project_id,
post_id,
language,
translate_targets,
parent
) do
receive do
{ref, _result} when is_reference(ref) ->
if MapSet.member?(known_refs, ref) do
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
new_task =
Task.async(fn ->
process_single_image(
next_path, project_id, post_id, language, translate_targets, parent
)
end)
drain_tasks(
rest,
[new_task | remaining_tasks],
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
project_id,
post_id,
language,
translate_targets,
parent
)
else
drain_tasks(
[next_path | rest], tasks, known_refs,
project_id, post_id, language, translate_targets, parent
)
end
{:DOWN, ref, :process, _pid, _reason} when is_reference(ref) ->
if MapSet.member?(known_refs, ref) do
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
new_task =
Task.async(fn ->
process_single_image(
next_path, project_id, post_id, language, translate_targets, parent
)
end)
drain_tasks(
rest,
[new_task | remaining_tasks],
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
project_id,
post_id,
language,
translate_targets,
parent
)
else
drain_tasks(
[next_path | rest], tasks, known_refs,
project_id, post_id, language, translate_targets, parent
)
end
end
end
defp pop_task_by_ref(tasks, ref) do
Enum.reduce(tasks, {nil, []}, fn
%{ref: ^ref} = task, {nil, rest} -> {task, rest}
task, {found, rest} -> {found, [task | rest]}
end)
end
defp process_single_image(
path, project_id, post_id, language, translate_targets, parent
) do
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
true <- String.starts_with?(media.mime_type || "", "image/"),
{:ok, result} <- AI.analyze_image(media.id, language: language),
{:ok, _updated} <- Media.update_media(media.id, %{
title: result.title,
alt: result.alt,
caption: result.caption
}),
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
translate_media_translations(media.id, translate_targets)
title = result.title || media.original_name
send(parent, {:add_image_processed, title})
else
false ->
:ok
{:error, reason} ->
Logger.warning("Image pipeline error for #{path}: #{inspect(reason)}")
send(parent, {:add_image_error, path, reason})
end
end
defp translate_media_translations(_media_id, []), do: :ok
defp translate_media_translations(media_id, [target | rest]) do
case AI.translate_media(media_id, target) do
{:ok, translation} ->
Media.upsert_media_translation(media_id, target, %{
title: translation.title,
alt: translation.alt,
caption: translation.caption
})
translate_media_translations(media_id, rest)
{:error, reason} ->
Logger.warning(
"Media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
)
translate_media_translations(media_id, rest)
end
end
end

View File

@@ -62,6 +62,18 @@ defmodule BDS.Desktop.ShellLive.Notify do
:ok :ok
end end
@spec schedule_auto_save(atom(), term()) :: :ok
def schedule_auto_save(type, id) do
send(self(), {:schedule_auto_save, type, id})
:ok
end
@spec cancel_auto_save(atom(), term()) :: :ok
def cancel_auto_save(type, id) do
send(self(), {:cancel_auto_save, type, id})
:ok
end
@spec parent(term()) :: :ok @spec parent(term()) :: :ok
def parent(message) do def parent(message) do
send(self(), message) send(self(), message)

View File

@@ -185,6 +185,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
Notify.dirty(:post, post_id, dirty?) Notify.dirty(:post, post_id, dirty?)
end end
if dirty? do
Notify.schedule_auto_save(:post, post_id)
end
{:noreply, socket} {:noreply, socket}
end end
@@ -204,6 +208,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
{:noreply, do_delete(socket)} {:noreply, do_delete(socket)}
end end
def handle_event("archive_post_editor", _params, socket) do
{:noreply, do_archive(socket)}
end
def handle_event("unarchive_post_editor", _params, socket) do
{:noreply, do_unarchive(socket)}
end
def handle_event("set_post_editor_mode", %{"mode" => mode}, socket) do def handle_event("set_post_editor_mode", %{"mode" => mode}, socket) do
normalized_mode = normalize_mode(mode) normalized_mode = normalize_mode(mode)
@@ -370,6 +382,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
editing_canonical_language?(translations, active_language, canonical_language), editing_canonical_language?(translations, active_language, canonical_language),
can_publish?: post.status == :draft, can_publish?: post.status == :draft,
can_delete?: post.status == :published, can_delete?: post.status == :published,
can_archive?: post.status in [:draft, :published],
can_unarchive?: post.status == :archived,
has_published_version?: has_published_version?(post), has_published_version?: has_published_version?(post),
discard_label: discard_label(post), discard_label: discard_label(post),
discard_title: discard_title(post), discard_title: discard_title(post),
@@ -461,6 +475,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
Atom.to_string(record_status(record))) Atom.to_string(record_status(record)))
Notify.dirty(:post, post.id, false) Notify.dirty(:post, post.id, false)
Notify.cancel_auto_save(:post, post.id)
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post saved")) notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post saved"))
socket socket
@@ -559,6 +574,72 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
defp do_archive(socket) do
case socket.assigns.post do
nil ->
socket
%Post{} = post ->
case Posts.archive_post(post.id) do
{:ok, archived_post} ->
socket =
socket
|> assign(:post, archived_post)
|> assign(:drafts, %{})
|> assign(:dirty?, false)
|> assign(:quick_actions_open?, false)
|> build_data()
Notify.tab_meta(
:post,
post.id,
archived_post.title || archived_post.slug || archived_post.id,
"archived"
)
Notify.dirty(:post, post.id, false)
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post archived"))
{:error, reason} ->
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|> build_data()
end
end
end
defp do_unarchive(socket) do
case socket.assigns.post do
nil ->
socket
%Post{} = post ->
case Posts.unarchive_post(post.id) do
{:ok, unarchived_post} ->
socket =
socket
|> assign(:post, unarchived_post)
|> assign(:drafts, %{})
|> assign(:dirty?, false)
|> assign(:quick_actions_open?, false)
|> build_data()
Notify.tab_meta(
:post,
post.id,
unarchived_post.title || unarchived_post.slug || unarchived_post.id,
"draft"
)
Notify.dirty(:post, post.id, false)
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post unarchived"))
{:error, reason} ->
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|> build_data()
end
end
end
defp do_detect_language(socket) do defp do_detect_language(socket) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) do
notify_output( notify_output(

View File

@@ -168,11 +168,19 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
@spec gallery_count(term()) :: term() @spec gallery_count(term()) :: term()
def gallery_count(form) do def gallery_count(form) do
form content = form |> Map.get("content", "") |> to_string()
|> Map.get("content", "")
|> to_string() image_count =
content
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1)) |> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|> length() |> length()
gallery_macro_count =
content
|> then(&Regex.scan(~r/\[\[gallery\]\]/i, &1))
|> length()
max(image_count, gallery_macro_count)
end end
@spec preview_url(term(), term(), term(), term()) :: term() @spec preview_url(term(), term(), term(), term()) :: term()

View File

@@ -61,6 +61,42 @@
<small><%= dgettext("ui", "Select a target language for this post") %></small> <small><%= dgettext("ui", "Select a target language for this post") %></small>
</span> </span>
</button> </button>
<%= if @post_editor.can_archive? or @post_editor.can_unarchive? do %>
<div class="quick-actions-divider"></div>
<%= if @post_editor.can_archive? do %>
<button
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
data-testid="post-archive-button"
type="button"
phx-click="archive_post_editor"
phx-target={@myself}
>
<span class="quick-action-icon">📦</span>
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= dgettext("ui", "Archive") %></strong>
<small><%= dgettext("ui", "Move this post to the archive") %></small>
</span>
</button>
<% end %>
<%= if @post_editor.can_unarchive? do %>
<button
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
data-testid="post-unarchive-button"
type="button"
phx-click="unarchive_post_editor"
phx-target={@myself}
>
<span class="quick-action-icon">📤</span>
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= dgettext("ui", "Unarchive") %></strong>
<small><%= dgettext("ui", "Restore this post to draft") %></small>
</span>
</button>
<% end %>
<% end %>
</div> </div>
<% end %> <% end %>
</div> </div>
@@ -362,6 +398,14 @@
> >
<%= dgettext("ui", "Insert Media") %> <%= dgettext("ui", "Insert Media") %>
</button> </button>
<button
class="add-gallery-images-button"
type="button"
phx-click="add_gallery_images"
phx-value-post-id={@post_editor.id}
>
<%= dgettext("ui", "Add Gallery Images") %>
</button>
<% end %> <% end %>
<%= if @post_editor.gallery_count > 0 do %> <%= if @post_editor.gallery_count > 0 do %>

View File

@@ -22,6 +22,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
"main_language" => metadata.main_language || "en", "main_language" => metadata.main_language || "en",
"default_author" => metadata.default_author || "", "default_author" => metadata.default_author || "",
"max_posts_per_page" => Integer.to_string(metadata.max_posts_per_page), "max_posts_per_page" => Integer.to_string(metadata.max_posts_per_page),
"image_import_concurrency" => Integer.to_string(metadata.image_import_concurrency),
"blogmark_category" => "blogmark_category" =>
metadata.blogmark_category || metadata.blogmark_category ||
List.first(metadata.categories) || "article", List.first(metadata.categories) || "article",
@@ -71,6 +72,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
main_language: blank_to_nil(Map.get(draft, "main_language")), main_language: blank_to_nil(Map.get(draft, "main_language")),
default_author: blank_to_nil(Map.get(draft, "default_author")), default_author: blank_to_nil(Map.get(draft, "default_author")),
max_posts_per_page: parse_integer(Map.get(draft, "max_posts_per_page"), 50), max_posts_per_page: parse_integer(Map.get(draft, "max_posts_per_page"), 50),
image_import_concurrency: parse_integer(Map.get(draft, "image_import_concurrency"), 4),
blogmark_category: blank_to_nil(Map.get(draft, "blogmark_category")), blogmark_category: blank_to_nil(Map.get(draft, "blogmark_category")),
blog_languages: Map.get(draft, "blog_languages", []), blog_languages: Map.get(draft, "blog_languages", []),
semantic_similarity_enabled: truthy?(Map.get(draft, "semantic_similarity_enabled")) semantic_similarity_enabled: truthy?(Map.get(draft, "semantic_similarity_enabled"))
@@ -85,6 +87,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
"main_language" => Map.get(params, "main_language", "en"), "main_language" => Map.get(params, "main_language", "en"),
"default_author" => Map.get(params, "default_author", ""), "default_author" => Map.get(params, "default_author", ""),
"max_posts_per_page" => Map.get(params, "max_posts_per_page", "50"), "max_posts_per_page" => Map.get(params, "max_posts_per_page", "50"),
"image_import_concurrency" => Map.get(params, "image_import_concurrency", "4"),
"blogmark_category" => Map.get(params, "blogmark_category", "article"), "blogmark_category" => Map.get(params, "blogmark_category", "article"),
"blog_languages" => List.wrap(Map.get(params, "blog_languages", [])), "blog_languages" => List.wrap(Map.get(params, "blog_languages", [])),
"semantic_similarity_enabled" => truthy?(Map.get(params, "semantic_similarity_enabled")) "semantic_similarity_enabled" => truthy?(Map.get(params, "semantic_similarity_enabled"))

View File

@@ -82,6 +82,10 @@
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Max Posts Per Page") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Max Posts Per Page") %></label></div>
<div class="setting-control"><input class="ui-input" type="number" min="1" max="500" name="settings_project[max_posts_per_page]" value={@settings_editor.project["max_posts_per_page"]} /></div> <div class="setting-control"><input class="ui-input" type="number" min="1" max="500" name="settings_project[max_posts_per_page]" value={@settings_editor.project["max_posts_per_page"]} /></div>
</div> </div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Image Import Concurrency") %></label></div>
<div class="setting-control"><input class="ui-input" type="number" min="1" max="8" name="settings_project[image_import_concurrency]" value={@settings_editor.project["image_import_concurrency"]} /></div>
</div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Category") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Category") %></label></div>
<div class="setting-control"> <div class="setting-control">

View File

@@ -257,6 +257,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
"media_grid" -> render_media_sidebar(assigns) "media_grid" -> render_media_sidebar(assigns)
"entity_list" -> render_entity_sidebar(assigns) "entity_list" -> render_entity_sidebar(assigns)
"nav_list" -> render_nav_sidebar(assigns) "nav_list" -> render_nav_sidebar(assigns)
"git" -> render_git_sidebar(assigns)
_other -> render_default_sidebar(assigns) _other -> render_default_sidebar(assigns)
end end
end end
@@ -483,6 +484,141 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
""" """
end end
defp render_git_sidebar(assigns) do
assigns = assign(assigns, :git_state, Map.get(assigns.sidebar_data, :git_state, "not_a_repo"))
~H"""
<div class="git-sidebar">
<%= if @git_state == "active" do %>
<%= render_git_active(assigns) %>
<% else %>
<%= render_git_not_a_repo(assigns) %>
<% end %>
</div>
"""
end
defp render_git_not_a_repo(assigns) do
~H"""
<section class="git-section git-not-a-repo">
<p class="git-empty-hint"><%= dgettext("ui", "This project is not a Git repository yet.") %></p>
<form class="git-init-form flex flex-col gap-2" data-testid="git-init-form" phx-submit="git_initialize">
<input
type="text"
name="git[remote_url]"
placeholder={dgettext("ui", "Remote URL (optional)")}
value={Map.get(@sidebar_data, :remote_url) || ""}
/>
<button class="git-action-button" data-testid="git-initialize" type="submit">
<%= dgettext("ui", "Initialize Git") %>
</button>
</form>
</section>
"""
end
defp render_git_active(assigns) do
~H"""
<header class="git-header">
<div class="git-branch-row flex items-center gap-2">
<span class="git-branch-icon">⎇</span>
<span class="git-branch" data-testid="git-branch"><%= @sidebar_data.branch %></span>
<%= if @sidebar_data.upstream do %>
<span class="git-upstream" data-testid="git-upstream"><%= @sidebar_data.upstream %></span>
<% end %>
</div>
<div class="git-tracking flex items-center gap-3">
<span class="git-ahead" data-testid="git-ahead" title={dgettext("ui", "Ahead")}>↑ <%= @sidebar_data.ahead %></span>
<span class="git-behind" data-testid="git-behind" title={dgettext("ui", "Behind")}>↓ <%= @sidebar_data.behind %></span>
</div>
<div class="git-sync-legend flex items-center gap-3">
<span class="git-legend-item"><span class="git-sync-dot git-sync-synced"></span><%= dgettext("ui", "Synced") %></span>
<span class="git-legend-item"><span class="git-sync-dot git-sync-local_only"></span><%= dgettext("ui", "Local only") %></span>
<span class="git-legend-item"><span class="git-sync-dot git-sync-remote_only"></span><%= dgettext("ui", "Remote only") %></span>
</div>
</header>
<div class="git-actions flex items-center gap-2">
<button class="git-action-button" data-testid="git-action-fetch" type="button" phx-click="git_fetch" title={dgettext("ui", "Fetch")}><%= dgettext("ui", "Fetch") %></button>
<button class="git-action-button" data-testid="git-action-pull" type="button" phx-click="git_pull" title={dgettext("ui", "Pull")}><%= dgettext("ui", "Pull") %></button>
<button class="git-action-button" data-testid="git-action-push" type="button" phx-click="git_push" title={dgettext("ui", "Push")}><%= dgettext("ui", "Push") %></button>
<button class="git-action-button" data-testid="git-action-prune-lfs" type="button" phx-click="git_prune_lfs" title={dgettext("ui", "Prune LFS")}><%= dgettext("ui", "Prune LFS") %></button>
</div>
<section class="git-section git-changes">
<div class="git-section-title">
<span><%= dgettext("ui", "Changes") %></span>
<span class="git-section-count"><%= length(@sidebar_data.status_files) %></span>
</div>
<form class="git-commit-form flex flex-col gap-2" data-testid="git-commit-form" phx-submit="git_commit">
<input type="text" name="git[message]" placeholder={dgettext("ui", "Commit message")} />
<button class="git-action-button" data-testid="git-commit" type="submit"><%= dgettext("ui", "Commit") %></button>
</form>
<%= if Enum.any?(@sidebar_data.status_files) do %>
<div class="git-status-list flex flex-col">
<%= for file <- @sidebar_data.status_files do %>
<button
class="git-status-file flex items-center justify-between gap-2"
data-testid="git-status-file"
data-route="git_diff"
type="button"
title={"#{file.label}: #{file.path}"}
phx-click="open_sidebar_item"
phx-value-route="git_diff"
phx-value-id={"git-diff:" <> file.path}
phx-value-title={file.path}
phx-value-subtitle={file.label}
>
<span class="git-status-path"><%= file.path %></span>
<span class={"git-status-badge git-status-#{file.status}"}><%= file.code %></span>
</button>
<% end %>
</div>
<% else %>
<p class="git-empty-hint"><%= dgettext("ui", "No changes") %></p>
<% end %>
</section>
<section class="git-section git-history">
<div class="git-section-title">
<span><%= dgettext("ui", "History") %></span>
</div>
<%= if Enum.any?(@sidebar_data.history_entries) do %>
<div class="git-history-list flex flex-col">
<%= for entry <- @sidebar_data.history_entries do %>
<button
class="git-history-entry flex flex-col"
data-testid="git-history-entry"
data-route="git_diff"
type="button"
phx-click="open_sidebar_item"
phx-value-route="git_diff"
phx-value-id={"git-diff:commit:" <> entry.short_hash}
phx-value-title={entry.short_hash}
phx-value-subtitle={entry.subject || ""}
>
<span class="git-history-subject"><%= entry.subject %></span>
<span class="git-history-meta flex items-center gap-2">
<span class={"git-sync-dot git-sync-#{entry.sync_status}"}></span>
<span class="git-history-hash"><%= entry.short_hash %></span>
<%= if entry.author do %><span class="git-history-author"><%= entry.author %></span><% end %>
<%= if entry.date do %><span class="git-history-date"><%= entry.date %></span><% end %>
</span>
</button>
<% end %>
</div>
<%= if @sidebar_data.has_more_history do %>
<p class="git-history-more"><%= dgettext("ui", "Older history available") %></p>
<% end %>
<% else %>
<p class="git-empty-hint"><%= dgettext("ui", "No commits yet") %></p>
<% end %>
</section>
"""
end
defp render_default_sidebar(assigns) do defp render_default_sidebar(assigns) do
~H""" ~H"""
<%= for section <- Map.get(@sidebar_data, :sections, []) do %> <%= for section <- Map.get(@sidebar_data, :sections, []) do %>

View File

@@ -16,6 +16,16 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
@tags_sections ~w(cloud manage merge) @tags_sections ~w(cloud manage merge)
@colour_presets ~w(
#ef4444 #f97316 #f59e0b #eab308 #84cc16
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
#ec4899 #64748b
)
@spec colour_presets() :: [String.t()]
def colour_presets, do: @colour_presets
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()} @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
@impl true @impl true
def update(%{action: :save} = assigns, socket) do def update(%{action: :save} = assigns, socket) do
@@ -107,6 +117,26 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
{:noreply, assign(socket, :tags_editor, tags_editor)} {:noreply, assign(socket, :tags_editor, tags_editor)}
end end
def handle_event("pick_new_tag_color", %{"color" => color}, socket) do
tags_editor =
Map.put(socket.assigns.tags_editor, :new_tag, %{
socket.assigns.tags_editor.new_tag
| "color" => color
})
{:noreply, assign(socket, :tags_editor, tags_editor)}
end
def handle_event("pick_edit_tag_color", %{"color" => color}, socket) do
tags_editor =
Map.put(socket.assigns.tags_editor, :edit_draft, %{
socket.assigns.tags_editor.edit_draft
| "color" => color
})
{:noreply, assign(socket, :tags_editor, tags_editor)}
end
def handle_event("save_tag_editor", _params, socket) do def handle_event("save_tag_editor", _params, socket) do
{:noreply, do_save(socket)} {:noreply, do_save(socket)}
end end
@@ -241,6 +271,55 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end end
end end
attr :color, :string, default: nil
attr :presets, :list, required: true
attr :pick_event, :string, required: true
attr :target, :any, required: true
defp colour_picker(assigns) do
~H"""
<div
class="colour-picker-wrap"
id={"cp-#{@pick_event}"}
phx-hook="ColourPicker"
data-pick-event={@pick_event}
data-target={if @target, do: @target.cid}
>
<button
type="button"
class="colour-picker-trigger"
style={"background-color: #{if @color in [nil, ""], do: "#3b82f6", else: @color}"}
phx-click={Phoenix.LiveView.JS.toggle(to: "#cp-#{@pick_event} .colour-picker-popover")}
/>
<div class="colour-picker-popover hidden">
<div class="colour-picker-grid">
<%= for preset <- @presets do %>
<button
type="button"
class={"colour-picker-swatch#{if normalize_hex(@color) == normalize_hex(preset), do: " selected", else: ""}"}
style={"background-color: #{preset}"}
phx-click={Phoenix.LiveView.JS.push(@pick_event, value: %{color: preset}, target: if(@target, do: @target.cid)) |> Phoenix.LiveView.JS.add_class("hidden", to: "#cp-#{@pick_event} .colour-picker-popover")}
/>
<% end %>
</div>
<div class="colour-picker-custom">
<label>#</label>
<input
type="text"
maxlength="7"
placeholder="RRGGBB"
value={if @color in [nil, ""], do: "", else: @color}
/>
</div>
</div>
</div>
"""
end
defp normalize_hex(nil), do: nil
defp normalize_hex(""), do: nil
defp normalize_hex(hex), do: String.downcase(hex)
defp load_data(socket) do defp load_data(socket) do
project_id = socket.assigns.project_id project_id = socket.assigns.project_id
@@ -280,7 +359,8 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
merge_target: merge_target:
Map.get(socket.assigns, :tags_editor, %{}) Map.get(socket.assigns, :tags_editor, %{})
|> Map.get(:merge_target, List.first(selected) || ""), |> Map.get(:merge_target, List.first(selected) || ""),
selected_section: selected_section selected_section: selected_section,
colour_presets: @colour_presets
} }
assign(socket, :tags_editor, data) assign(socket, :tags_editor, data)

View File

@@ -38,7 +38,13 @@
<form class="tag-create-form" phx-change="change_new_tag_editor" phx-target={@myself}> <form class="tag-create-form" phx-change="change_new_tag_editor" phx-target={@myself}>
<div class="tag-form-row flex flex-wrap items-center gap-3"> <div class="tag-form-row flex flex-wrap items-center gap-3">
<input class="ui-input" type="text" name="new_tag[name]" value={@tags_editor.new_tag["name"]} placeholder={dgettext("ui", "Tag name")} /> <input class="ui-input" type="text" name="new_tag[name]" value={@tags_editor.new_tag["name"]} placeholder={dgettext("ui", "Tag name")} />
<input type="color" name="new_tag[color]" value={if(@tags_editor.new_tag["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.new_tag["color"])} /> <input type="hidden" name="new_tag[color]" value={@tags_editor.new_tag["color"] || ""} />
<.colour_picker
color={@tags_editor.new_tag["color"]}
presets={@tags_editor.colour_presets}
pick_event="pick_new_tag_color"
target={@myself}
/>
<button class="primary ui-button ui-button-primary" type="button" phx-click="create_tag_editor" phx-target={@myself}><%= dgettext("ui", "Create") %></button> <button class="primary ui-button ui-button-primary" type="button" phx-click="create_tag_editor" phx-target={@myself}><%= dgettext("ui", "Create") %></button>
</div> </div>
</form> </form>
@@ -47,7 +53,13 @@
<form class="tag-edit-form" phx-change="change_edit_tag_editor" phx-target={@myself}> <form class="tag-edit-form" phx-change="change_edit_tag_editor" phx-target={@myself}>
<div class="tag-form-row flex flex-wrap items-center gap-3"> <div class="tag-form-row flex flex-wrap items-center gap-3">
<input class="ui-input" type="text" name="edit_tag[name]" value={@tags_editor.edit_draft["name"]} /> <input class="ui-input" type="text" name="edit_tag[name]" value={@tags_editor.edit_draft["name"]} />
<input type="color" name="edit_tag[color]" value={if(@tags_editor.edit_draft["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.edit_draft["color"])} /> <input type="hidden" name="edit_tag[color]" value={@tags_editor.edit_draft["color"] || ""} />
<.colour_picker
color={@tags_editor.edit_draft["color"]}
presets={@tags_editor.colour_presets}
pick_event="pick_edit_tag_color"
target={@myself}
/>
<select class="ui-input" name="edit_tag[post_template_slug]"> <select class="ui-input" name="edit_tag[post_template_slug]">
<option value=""><%= dgettext("ui", "No Template") %></option> <option value=""><%= dgettext("ui", "No Template") %></option>
<%= for template <- @tags_editor.templates do %> <%= for template <- @tags_editor.templates do %>

View File

@@ -61,9 +61,26 @@ defmodule BDS.Desktop.Shutdown do
def command_menu_selected(_event, _command_event), do: :ok def command_menu_selected(_event, _command_event), do: :ok
@doc """
Terminate the OS process directly with SIGKILL.
`Desktop.Window.quit/0` routes through `System.halt/1`, which calls the libc
`exit()` and runs the wxWidgets C++ static destructors on the way out. On
macOS that races the still-running wx event loop on the main thread and
segfaults (`wxMenu::~wxMenu` vs `wxAppBase::ProcessIdle`). A SIGKILL is a
kernel-level termination that skips those destructors entirely, so the app
exits cleanly without producing a crash report.
"""
@spec quit() :: :ok
def quit do
kill_heart()
kill_beam()
:ok
end
defp start_shutdown_task do defp start_shutdown_task do
Task.start(fn -> Task.start(fn ->
MainWindow.persist_now() persist_safely()
maybe_hide_window() maybe_hide_window()
Process.sleep(50) Process.sleep(50)
quit_module().quit() quit_module().quit()
@@ -72,6 +89,57 @@ defmodule BDS.Desktop.Shutdown do
:ok :ok
end end
defp persist_safely do
MainWindow.persist_now()
:ok
rescue
_error -> :ok
catch
:exit, _reason -> :ok
end
# heart, when present, would relaunch the app after we kill the BEAM, so it
# has to be terminated first. When the app is started without heart (e.g. via
# `mix`) there is nothing to do here.
defp kill_heart do
with heart when is_pid(heart) <- Process.whereis(:heart),
{:links, links} <- Process.info(heart, :links),
port when is_port(port) <- Enum.find(links, &is_port/1),
{:os_pid, heart_pid} <- Port.info(port, :os_pid) do
os_kill(heart_pid)
else
_ -> :ok
end
rescue
_error -> :ok
catch
:exit, _reason -> :ok
end
defp kill_beam do
os_kill(:os.getpid())
end
defp os_kill(os_pid) do
os_kill_fun().(os_pid)
:ok
rescue
_error -> :ok
catch
:exit, _reason -> :ok
end
defp os_kill_fun do
Application.get_env(:bds, :desktop_os_kill_fun, &__MODULE__.hard_kill/1)
end
@doc false
@spec hard_kill(charlist() | integer() | String.t()) :: :ok
def hard_kill(os_pid) do
System.cmd("kill", ["-9", to_string(os_pid)], stderr_to_stdout: true)
:ok
end
defp maybe_hide_window do defp maybe_hide_window do
module = window_module() module = window_module()
@@ -86,8 +154,10 @@ defmodule BDS.Desktop.Shutdown do
:exit, _reason -> :ok :exit, _reason -> :ok
end end
defp quit_module do @doc false
Application.get_env(:bds, :desktop_window_quit_module, Window) @spec quit_module() :: module()
def quit_module do
Application.get_env(:bds, :desktop_window_quit_module, __MODULE__)
end end
defp window_module do defp window_module do

View File

@@ -2,6 +2,7 @@ defmodule BDS.Embeddings do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
require Logger
alias BDS.Persistence alias BDS.Persistence
alias BDS.Embeddings.DismissedDuplicatePair alias BDS.Embeddings.DismissedDuplicatePair
@@ -75,25 +76,16 @@ defmodule BDS.Embeddings do
) )
existing_keys = preload_keys_by_post_id(project_id, Enum.map(posts, & &1.id)) 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)
case build_key_rows(posts, existing_keys, max_label_value(), nil, false) do
{:ok, rows} ->
batch_upsert_keys(rows) batch_upsert_keys(rows)
:ok = rebuild_snapshot(project_id) :ok = rebuild_snapshot(project_id)
{:ok, Enum.map(posts, & &1.id)} {:ok, Enum.map(posts, & &1.id)}
{:error, _reason} = error ->
error
end
else else
{:ok, []} {:ok, []}
end end
@@ -113,9 +105,6 @@ defmodule BDS.Embeddings do
) )
post_ids = Enum.map(posts, & &1.id) post_ids = Enum.map(posts, & &1.id)
total_posts = length(posts)
:ok = report_rebuild_started(on_progress, total_posts, "embedding entries")
Repo.delete_all( Repo.delete_all(
from key in Key, from key in Key,
@@ -123,30 +112,19 @@ defmodule BDS.Embeddings do
) )
existing_keys = preload_keys_by_post_id(project_id) existing_keys = preload_keys_by_post_id(project_id)
base_label = max_label_value()
{rows, _next_label} =
posts
|> Enum.with_index(1)
|> 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)
# An explicit rebuild re-embeds every post from scratch (ReindexAll),
# ignoring the content_hash skip optimisation.
case build_key_rows(posts, existing_keys, max_label_value(), on_progress, true) do
{:ok, rows} ->
batch_upsert_keys(rows) batch_upsert_keys(rows)
:ok = report_rebuild_phase(on_progress, 0.99, "Persisting embedding snapshot") :ok = report_rebuild_phase(on_progress, 0.99, "Persisting embedding snapshot")
:ok = rebuild_snapshot(project_id) :ok = rebuild_snapshot(project_id)
{:ok, post_ids} {:ok, post_ids}
{:error, _reason} = error ->
error
end
else else
{:ok, []} {:ok, []}
end end
@@ -200,16 +178,15 @@ defmodule BDS.Embeddings do
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
%Key{content_hash: ^content_hash} -> %Key{content_hash: ^content_hash} ->
if Keyword.get(opts, :refresh_index, true) and # Embedding is already current. The HNSW index self-heals on query
snapshot_content_hash(post.project_id, post.id) != content_hash do # (find_similar/find_duplicates rebuild when no index is loaded), so
:ok = rebuild_snapshot(post.project_id) # there is nothing to refresh here.
end
:ok :ok
existing_key -> existing_key ->
case embed_text(raw_text, post.language) do
{:ok, vector} ->
label = existing_key_label(existing_key) || next_label() label = existing_key_label(existing_key) || next_label()
{:ok, vector} = embed_text(raw_text, post.language)
(existing_key || %Key{}) (existing_key || %Key{})
|> Key.changeset(%{ |> Key.changeset(%{
@@ -217,7 +194,7 @@ defmodule BDS.Embeddings do
post_id: post.id, post_id: post.id,
project_id: post.project_id, project_id: post.project_id,
content_hash: content_hash, content_hash: content_hash,
vector: Jason.encode!(vector) vector: encode_vector(vector)
}) })
|> Repo.insert_or_update() |> Repo.insert_or_update()
@@ -226,6 +203,17 @@ defmodule BDS.Embeddings do
end end
:ok :ok
{:error, reason} ->
# Embedding is best-effort on post save: if the model is unavailable
# (e.g. offline first-use download), leave the post unindexed rather
# than failing the save. An explicit reindex surfaces the error.
Logger.warning(
"Embedding unavailable for post #{post.id}: #{inspect(reason)}; left unindexed"
)
:ok
end
end end
end end
@@ -246,17 +234,100 @@ defmodule BDS.Embeddings do
Repo.one(from key in Key, select: max(key.label)) || 0 Repo.one(from key in Key, select: max(key.label)) || 0
end end
defp compute_key_data(%Post{} = post, existing_key, next_label) do # Builds the upsert rows for a batch of posts. Unless `force?` is set, posts
body = resolve_post_body(post) # whose content_hash is unchanged are skipped (ContentHashSkipsUnchanged); the
raw_text = compose_embedding_source(post.title, body) # rest are embedded in batches (see embed_pending/2) so model inference is not
# serialised one post at a time. Labels keep their existing value or take the
# next free integer. Returns `{:error, reason}` if the model is unavailable.
defp build_key_rows(posts, existing_keys, base_label, on_progress, force?) do
prepared =
Enum.map(posts, fn post ->
raw_text = compose_embedding_source(post.title, resolve_post_body(post))
existing = Map.get(existing_keys, post.id)
content_hash = hash_text(raw_text) content_hash = hash_text(raw_text)
if existing_key && existing_key.content_hash == content_hash do %{
:skip post: post,
existing: existing,
raw_text: raw_text,
content_hash: content_hash,
needs_embed?: force? or is_nil(existing) or existing.content_hash != content_hash
}
end)
pending = Enum.filter(prepared, & &1.needs_embed?)
:ok = report_rebuild_started(on_progress, length(pending), "embedding entries")
case embed_pending(pending, on_progress) do
{:ok, vectors_by_post_id} -> {:ok, collect_rows(prepared, vectors_by_post_id, base_label)}
{:error, _reason} = error -> error
end
end
defp collect_rows(prepared, vectors_by_post_id, base_label) do
{rows, _next_label} =
Enum.reduce(prepared, {[], base_label + 1}, fn entry, {acc, next_label} ->
if entry.needs_embed? do
vector = Map.fetch!(vectors_by_post_id, entry.post.id)
label = if entry.existing, do: entry.existing.label, else: next_label
bump = if entry.existing, do: 0, else: 1
row = [
label,
entry.post.id,
entry.post.project_id,
entry.content_hash,
encode_vector(vector)
]
{[row | acc], next_label + bump}
else else
{:ok, vector} = embed_text(raw_text, post.language) {acc, next_label}
label = if existing_key, do: existing_key.label, else: next_label end
{:upsert, [label, post.id, post.project_id, content_hash, Jason.encode!(vector)]} end)
rows
end
defp embed_pending([], _on_progress), do: {:ok, %{}}
defp embed_pending(pending, on_progress) do
total = length(pending)
batch = batch_size()
pending
# Group by language so the lexical stub stems consistently; the neural
# backend is multilingual and ignores the language hint.
|> Enum.group_by(& &1.post.language)
|> Enum.reduce_while({%{}, 0}, fn {language, group}, acc ->
group
|> Enum.chunk_every(batch)
|> Enum.reduce_while(acc, fn chunk, {vectors, done} ->
case embed_many(Enum.map(chunk, & &1.raw_text), language) do
{:ok, chunk_vectors} ->
vectors =
chunk
|> Enum.zip(chunk_vectors)
|> Enum.reduce(vectors, fn {entry, vector}, acc ->
Map.put(acc, entry.post.id, vector)
end)
done = done + length(chunk)
:ok = report_rebuild_progress(on_progress, done, total, "embedding entries")
{:cont, {vectors, done}}
{:error, reason} ->
{:halt, {:error, reason}}
end
end)
|> case do
{:error, reason} -> {:halt, {:error, reason}}
accumulator -> {:cont, accumulator}
end
end)
|> case do
{:error, reason} -> {:error, reason}
{vectors, _done} -> {:ok, vectors}
end end
end end
@@ -308,22 +379,9 @@ defmodule BDS.Embeddings do
) )
existing_keys = preload_keys_by_post_id(project_id) existing_keys = preload_keys_by_post_id(project_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)
case build_key_rows(posts, existing_keys, max_label_value(), nil, false) do
{:ok, rows} ->
batch_upsert_keys(rows) batch_upsert_keys(rows)
:ok = rebuild_snapshot(project_id) :ok = rebuild_snapshot(project_id)
@@ -331,6 +389,10 @@ defmodule BDS.Embeddings do
Repo.all(from key in Key, where: key.project_id == ^project_id, select: key.post_id) Repo.all(from key in Key, where: key.project_id == ^project_id, select: key.post_id)
{:ok, indexed} {:ok, indexed}
{:error, _reason} = error ->
error
end
else else
{:ok, []} {:ok, []}
end end
@@ -344,28 +406,28 @@ defmodule BDS.Embeddings do
{:error, :not_found} -> {:error, :not_found} ->
{:ok, []} {:ok, []}
{:ok, post, source_vector} -> {:ok, _post, nil} ->
similar = {:ok, []}
case Index.neighbors(post.project_id, post.id, limit) do
{:ok, post, %Key{} = key} ->
{:ok, query_similar(post.project_id, key, limit)}
end
end
# Queries the HNSW index for a post's neighbours, rebuilding the index from
# the DB vectors if it is not currently loaded (e.g. after a restart).
defp query_similar(project_id, %Key{} = key, limit) do
case Index.neighbors(project_id, key.label, key.vector, limit) do
{:ok, neighbors} -> {:ok, neighbors} ->
neighbors neighbors
{:error, :missing} -> {:error, :missing} ->
Repo.all( :ok = rebuild_snapshot(project_id)
from key in Key,
where: key.project_id == ^post.project_id and key.post_id != ^post.id
)
|> Enum.map(fn key ->
%{
post_id: key.post_id,
score: cosine_similarity(source_vector, decode_vector(key.vector))
}
end)
|> Enum.sort_by(& &1.score, :desc)
|> Enum.take(max(limit, 0))
end
{:ok, similar} case Index.neighbors(project_id, key.label, key.vector, limit) do
{:ok, neighbors} -> neighbors
{:error, :missing} -> []
end
end end
end end
@@ -378,8 +440,12 @@ defmodule BDS.Embeddings do
{:error, :not_found} -> {:error, :not_found} ->
{:ok, %{}} {:ok, %{}}
{:ok, post, source_vector} -> {:ok, _post, nil} ->
{:ok, %{}}
{:ok, post, %Key{} = source_key} ->
target_ids = Enum.uniq(target_post_ids) target_ids = Enum.uniq(target_post_ids)
source_vector = decode_vector(source_key.vector)
scores = scores =
Repo.all( Repo.all(
@@ -435,47 +501,19 @@ defmodule BDS.Embeddings do
if enabled_for_project?(project_id) do if enabled_for_project?(project_id) do
on_progress = progress_callback(opts) on_progress = progress_callback(opts)
dismissed = dismissed_pair_keys(project_id) dismissed = dismissed_pair_keys(project_id)
entries = load_index_entries(project_id)
pairs =
case duplicate_pairs_with_rebuild(project_id, entries, on_progress) do
{:ok, pairs} -> pairs
{:error, :missing} -> []
end
duplicates = duplicates =
case Index.duplicate_pairs(project_id, @duplicate_threshold, on_progress: on_progress) do
{:ok, pairs} ->
pairs pairs
|> Enum.reject(fn pair -> pair_key(pair.post_id_a, pair.post_id_b) in dismissed end) |> Enum.reject(fn pair -> pair_key(pair.post_id_a, pair.post_id_b) in dismissed end)
|> enrich_duplicate_pairs(project_id) |> enrich_duplicate_pairs(project_id)
{:error, :missing} ->
keys =
Repo.all(
from key in Key,
where: key.project_id == ^project_id,
order_by: [asc: key.post_id]
)
total_keys = length(keys)
:ok = report_rebuild_started(on_progress, total_keys, "embedding entries")
keys
|> Enum.with_index(1)
|> Enum.flat_map(fn {left, index} ->
:ok = report_rebuild_progress(on_progress, index, total_keys, "embedding entries")
for right <- keys,
left.post_id < right.post_id,
pair_key(left.post_id, right.post_id) not in dismissed,
similarity =
cosine_similarity(decode_vector(left.vector), decode_vector(right.vector)),
similarity >= @duplicate_threshold do
%{
post_id_a: left.post_id,
post_id_b: right.post_id,
score: similarity
}
end
end)
|> enrich_duplicate_pairs(project_id)
end
:ok = report_rebuild_phase(on_progress, 0.99, "Resolving duplicate candidates") :ok = report_rebuild_phase(on_progress, 0.99, "Resolving duplicate candidates")
{:ok, duplicates} {:ok, duplicates}
else else
@@ -538,17 +576,33 @@ defmodule BDS.Embeddings do
with {:ok, post} <- fetch_post(post_id) do with {:ok, post} <- fetch_post(post_id) do
if enabled_for_project?(post.project_id) do if enabled_for_project?(post.project_id) do
:ok = ensure_key(post) :ok = ensure_key(post)
{:ok, post, Repo.get_by(Key, post_id: post.id, project_id: post.project_id)}
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
nil -> {:ok, post, []}
key -> {:ok, post, decode_vector(key.vector)}
end
else else
{:disabled, post.project_id} {:disabled, post.project_id}
end end
end end
end end
defp duplicate_pairs_with_rebuild(project_id, entries, on_progress) do
case Index.duplicate_pairs(project_id, entries, @duplicate_threshold, on_progress: on_progress) do
{:ok, pairs} ->
{:ok, pairs}
{:error, :missing} ->
:ok = rebuild_snapshot(project_id)
Index.duplicate_pairs(project_id, entries, @duplicate_threshold, on_progress: on_progress)
end
end
defp load_index_entries(project_id) do
Repo.all(
from key in Key,
where: key.project_id == ^project_id,
order_by: [asc: key.post_id]
)
|> Enum.map(fn key -> %{label: key.label, post_id: key.post_id, vector: key.vector} end)
end
defp ensure_key(%Post{} = post) do defp ensure_key(%Post{} = post) do
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
nil -> sync_post(post) nil -> sync_post(post)
@@ -655,11 +709,42 @@ defmodule BDS.Embeddings do
end end
defp embed_text(raw_text, language) do defp embed_text(raw_text, language) do
configured_backend().embed("query: " <> raw_text, language: language) # Per-backend preprocessing (e5 "query: " prefix, pooling, normalisation)
# is the backend's responsibility — see BDS.Embeddings.Backends.Neural.
configured_backend().embed(raw_text, language: language)
end
# Embeds a batch of texts in one shot. Backends that implement the optional
# embed_many/2 callback (e.g. the neural backend, which feeds them through the
# model as a single batched inference run) handle the whole list; others fall
# back to sequential single embeds.
defp embed_many(texts, language) do
backend = configured_backend()
if function_exported?(backend, :embed_many, 2) do
backend.embed_many(texts, language: language)
else
Enum.reduce_while(texts, {:ok, []}, fn text, {:ok, acc} ->
case backend.embed(text, language: language) do
{:ok, vector} -> {:cont, {:ok, [vector | acc]}}
{:error, _reason} = error -> {:halt, error}
end
end)
|> case do
{:ok, vectors} -> {:ok, Enum.reverse(vectors)}
{:error, _reason} = error -> error
end
end
end
defp batch_size do
Application.get_env(:bds, :embeddings, [])
|> Keyword.get(:batch_size, 16)
|> max(1)
end end
defp rebuild_snapshot(project_id) do defp rebuild_snapshot(project_id) do
Index.rebuild(project_id, model_id: model_id(), dimensions: dimensions()) Index.put(project_id, dimensions(), load_index_entries(project_id))
end end
defp progress_callback(opts), do: ProgressReporter.callback(opts) defp progress_callback(opts), do: ProgressReporter.callback(opts)
@@ -684,13 +769,6 @@ defmodule BDS.Embeddings do
defp report_rebuild_phase(callback, value, label), defp report_rebuild_phase(callback, value, label),
do: ProgressReporter.report_phase(callback, value, label) do: ProgressReporter.report_phase(callback, value, label)
defp snapshot_content_hash(project_id, post_id) do
case Index.read(project_id) do
{:ok, snapshot} -> get_in(snapshot, ["entries", post_id, "content_hash"])
_other -> nil
end
end
defp current_embedding_status(nil, _expected_hash), do: "missing" defp current_embedding_status(nil, _expected_hash), do: "missing"
defp current_embedding_status(%Key{vector: vector}, _expected_hash) when vector in [nil, ""], defp current_embedding_status(%Key{vector: vector}, _expected_hash) when vector in [nil, ""],
@@ -726,8 +804,22 @@ defmodule BDS.Embeddings do
defp hash_text(text), do: :crypto.hash(:sha256, text) |> Base.encode16(case: :lower) defp hash_text(text), do: :crypto.hash(:sha256, text) |> Base.encode16(case: :lower)
# Vectors are persisted as a packed little-endian Float32 BLOB
# (`dimensions` * 4 bytes; 1536 bytes for multilingual-e5-small) per the
# VectorCacheInDb invariant in specs/embedding.allium.
defp encode_vector(values) when is_list(values) do
for value <- values, into: <<>>, do: <<float32(value)::float-32-little>>
end
defp float32(value) when is_float(value), do: value
defp float32(value) when is_integer(value), do: value * 1.0
defp decode_vector(nil), do: [] defp decode_vector(nil), do: []
defp decode_vector(vector), do: Jason.decode!(vector) defp decode_vector(<<>>), do: []
defp decode_vector(binary) when is_binary(binary) do
for <<value::float-32-little <- binary>>, do: value
end
defp cosine_similarity([], _other), do: 0.0 defp cosine_similarity([], _other), do: 0.0
defp cosine_similarity(_vector, []), do: 0.0 defp cosine_similarity(_vector, []), do: 0.0

View File

@@ -3,4 +3,15 @@ defmodule BDS.Embeddings.Backend do
@callback model_info() :: %{model_id: String.t(), dimensions: pos_integer()} @callback model_info() :: %{model_id: String.t(), dimensions: pos_integer()}
@callback embed(String.t(), keyword()) :: {:ok, [number()]} | {:error, term()} @callback embed(String.t(), keyword()) :: {:ok, [number()]} | {:error, term()}
@doc """
Embeds a list of texts in a single call.
Backends that can amortise work across inputs (e.g. running the neural model
on a batched tensor) should implement this. The result list is aligned with
the input list. Optional — callers fall back to repeated `embed/2`.
"""
@callback embed_many([String.t()], keyword()) :: {:ok, [[number()]]} | {:error, term()}
@optional_callbacks embed_many: 2
end end

View File

@@ -1,5 +1,13 @@
defmodule BDS.Embeddings.Backends.InApp do defmodule BDS.Embeddings.Backends.InApp do
@moduledoc false @moduledoc """
Deterministic lexical embedding stub.
This backend does NOT satisfy the `RealNeuralModel` invariant — it projects
stemmed tokens and bigrams into a sparse hashed vector. It exists only as an
offline, dependency-free fallback for tests and environments where the neural
model (see `BDS.Embeddings.Backends.Neural`) cannot be loaded. Production and
development use the neural backend.
"""
@behaviour BDS.Embeddings.Backend @behaviour BDS.Embeddings.Backend
@@ -29,6 +37,17 @@ defmodule BDS.Embeddings.Backends.InApp do
{:ok, vector} {:ok, vector}
end end
@impl true
def embed_many(texts, opts) when is_list(texts) and is_list(opts) do
vectors =
Enum.map(texts, fn text ->
{:ok, vector} = embed(text, opts)
vector
end)
{:ok, vectors}
end
defp tokenize(text) do defp tokenize(text) do
Regex.scan(~r/[[:alnum:]]+/u, String.downcase(text)) Regex.scan(~r/[[:alnum:]]+/u, String.downcase(text))
|> List.flatten() |> List.flatten()

View File

@@ -0,0 +1,152 @@
defmodule BDS.Embeddings.Backends.Neural do
@moduledoc """
Real on-device neural embedding backend.
Implements the `RealNeuralModel` and `ModelCaching` invariants from
`specs/embedding.allium`: embeddings are produced by the actual
multilingual-e5-small transformer (the `intfloat/multilingual-e5-small`
weights behind the `Xenova/multilingual-e5-small` identifier) via
Bumblebee + EXLA, never by a lexical approximation.
* Lazy-loaded — the model pipeline is built on the first embedding
request, not at application startup.
* Model files (~100 MB) are downloaded from the Hugging Face Hub on
first use and cached on disk (Bumblebee cache dir), persisting across
sessions and project switches.
* Text preprocessing follows the e5 convention: every input is prefixed
with `"query: "`, pooled with mean pooling over the attention mask, and
L2-normalised. This is what makes cross-language semantic similarity
work.
* Inference is batched. `embed_many/2` runs the model on `batch_size`
texts per compiled inference run instead of one at a time, which is the
dominant cost when (re)indexing large numbers of posts. The serving is
compiled for a fixed `batch_size`/`sequence_length` (configurable);
shorter sequences mean less wasted transformer compute.
EXLA on Apple Silicon runs on the CPU — XLA has no Metal/GPU backend. See
SPECGAPS A1-14c for the planned EMLX (Apple GPU via MLX) acceleration path.
"""
@behaviour BDS.Embeddings.Backend
use GenServer
@query_prefix "query: "
@embed_timeout :timer.minutes(10)
@default_model_id "Xenova/multilingual-e5-small"
@default_model_repo "intfloat/multilingual-e5-small"
@default_dimensions 384
@default_batch_size 16
@default_sequence_length 256
def child_spec(opts) do
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}}
end
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl BDS.Embeddings.Backend
def model_info do
config = config()
%{
model_id: Keyword.get(config, :model_id, @default_model_id),
dimensions: Keyword.get(config, :dimensions, @default_dimensions)
}
end
@impl BDS.Embeddings.Backend
def embed(text, _opts) when is_binary(text) do
case run([@query_prefix <> text]) do
{:ok, [vector]} -> {:ok, vector}
{:ok, _other} -> {:error, :unexpected_embedding_result}
{:error, _reason} = error -> error
end
end
@impl BDS.Embeddings.Backend
def embed_many([], _opts), do: {:ok, []}
def embed_many(texts, _opts) when is_list(texts) do
run(Enum.map(texts, &(@query_prefix <> &1)))
end
defp run(prefixed_texts) do
GenServer.call(__MODULE__, {:embed, prefixed_texts}, @embed_timeout)
catch
:exit, reason -> {:error, {:embedding_backend_unavailable, reason}}
end
@impl GenServer
def init(_opts), do: {:ok, %{serving: nil}}
@impl GenServer
def handle_call({:embed, texts}, _from, state) do
case ensure_serving(state) do
{:ok, %{serving: serving} = next_state} ->
vectors =
texts
|> Enum.chunk_every(batch_size())
|> Enum.flat_map(&run_chunk(serving, &1))
{:reply, {:ok, vectors}, next_state}
{:error, _reason} = error ->
{:reply, error, state}
end
rescue
exception ->
{:reply, {:error, Exception.message(exception)}, state}
end
defp run_chunk(serving, [single]) do
%{embedding: tensor} = Nx.Serving.run(serving, single)
[Nx.to_flat_list(tensor)]
end
defp run_chunk(serving, chunk) do
serving
|> Nx.Serving.run(chunk)
|> Enum.map(fn %{embedding: tensor} -> Nx.to_flat_list(tensor) end)
end
defp ensure_serving(%{serving: nil} = state) do
case build_serving() do
{:ok, serving} -> {:ok, %{state | serving: serving}}
{:error, _reason} = error -> error
end
end
defp ensure_serving(state), do: {:ok, state}
defp build_serving do
repo = {:hf, Keyword.get(config(), :model_repo, @default_model_repo)}
with {:ok, model_info} <- Bumblebee.load_model(repo),
{:ok, tokenizer} <- Bumblebee.load_tokenizer(repo) do
serving =
Bumblebee.Text.text_embedding(model_info, tokenizer,
output_pool: :mean_pooling,
output_attribute: :hidden_state,
embedding_processor: :l2_norm,
compile: [batch_size: batch_size(), sequence_length: sequence_length()],
defn_options: [compiler: EXLA]
)
{:ok, serving}
end
end
defp batch_size do
config() |> Keyword.get(:batch_size, @default_batch_size) |> max(1)
end
defp sequence_length do
config() |> Keyword.get(:sequence_length, @default_sequence_length) |> max(1)
end
defp config, do: Application.get_env(:bds, :embeddings, [])
end

View File

@@ -1,214 +1,342 @@
defmodule BDS.Embeddings.Index do defmodule BDS.Embeddings.Index do
@moduledoc false @moduledoc """
Per-project approximate-nearest-neighbour index over post embeddings.
import Ecto.Query Backed by an HNSW graph (hnswlib) per the A1-14b / `specs/embedding.allium`
requirement — cosine space, connectivity M=16, efConstruction=128,
efSearch=64. This replaces the previous O(n²) brute-force cosine snapshot:
building is O(n·log n) and queries are O(log n).
The process is intentionally **database-free**: callers (running in their own
process, e.g. under the test SQL sandbox) read embedding vectors from the DB
and hand them in. This GenServer owns only the in-memory HNSW graphs, the
`label → post_id` maps, and file persistence.
Persistence (DebouncedPersistence invariant): the index file
(`embeddings.usearch`) plus a small sidecar holding the dimension and the
label→post_id map are written behind a 5s debounce, and force-saved on
project switch / shutdown. On a cold query the index is lazily reloaded from
those files; if they are absent the caller rebuilds from the DB vectors.
"""
use GenServer
alias BDS.Persistence
alias BDS.Embeddings.Key
alias BDS.Projects alias BDS.Projects
alias BDS.ProgressReporter alias BDS.ProgressReporter
alias BDS.Repo
@neighbor_limit 21 @neighbor_limit 21
@debounce_ms 5_000
@space :cosine
@m 16
@ef_construction 128
@ef_search 64
# ─── Public API ─────────────────────────────────────────────
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc "On-disk path of the HNSW index file for a project."
def path(project_id) when is_binary(project_id) do def path(project_id) when is_binary(project_id) do
Path.join(Projects.project_cache_dir(project_id), "embeddings.usearch") Path.join(Projects.project_cache_dir(project_id), "embeddings.usearch")
end end
def rebuild(project_id, opts) when is_binary(project_id) and is_list(opts) do @doc """
model_id = Keyword.fetch!(opts, :model_id) (Re)builds the index for a project from the given entries and schedules a
dimensions = Keyword.fetch!(opts, :dimensions) debounced save. `entries` is a list of `%{label:, post_id:, vector:}` where
`vector` is the packed little-endian Float32 BLOB.
"""
def put(project_id, dimensions, entries)
when is_binary(project_id) and is_integer(dimensions) and is_list(entries) do
GenServer.call(__MODULE__, {:put, project_id, dimensions, entries}, :infinity)
end
keys = @doc """
Repo.all( Returns up to `limit` nearest neighbours of `query_vector` (the post's packed
from key in Key, BLOB), excluding `query_label`. `{:error, :missing}` if no index is available.
where: key.project_id == ^project_id, """
order_by: [asc: key.post_id] def neighbors(project_id, query_label, query_vector, limit)
when is_binary(project_id) and is_integer(query_label) and is_binary(query_vector) do
GenServer.call(__MODULE__, {:neighbors, project_id, query_label, query_vector, limit}, :infinity)
end
@doc """
Finds near-duplicate pairs at/above `threshold` by querying the HNSW graph for
each entry's neighbours. `{:error, :missing}` if no index is available.
"""
def duplicate_pairs(project_id, entries, threshold, opts \\ [])
when is_binary(project_id) and is_list(entries) and is_number(threshold) do
GenServer.call(
__MODULE__,
{:duplicate_pairs, project_id, entries, threshold, opts},
:infinity
) )
end
entries = @doc "Forces a pending save for a project to disk now (e.g. on project switch)."
keys def flush(project_id) when is_binary(project_id) do
|> Enum.map(fn key -> GenServer.call(__MODULE__, {:flush, project_id}, :infinity)
vector = decode_vector(key.vector) end
@doc "Forces all pending saves to disk now (e.g. on shutdown)."
def flush_all do
GenServer.call(__MODULE__, :flush_all, :infinity)
end
@doc "Drops the in-memory index for a project (e.g. on project deletion)."
def forget(project_id) when is_binary(project_id) do
GenServer.call(__MODULE__, {:forget, project_id}, :infinity)
end
# ─── GenServer ──────────────────────────────────────────────
@impl true
def init(_opts) do
Process.flag(:trap_exit, true)
{:ok, %{}}
end
@impl true
def handle_call({:put, project_id, dimensions, entries}, _from, state) do
entry = build_entry(dimensions, entries)
state = state |> Map.put(project_id, entry) |> schedule_save(project_id)
{:reply, :ok, state}
end
def handle_call({:neighbors, project_id, query_label, query_vector, limit}, _from, state) do
case ensure_loaded(state, project_id) do
{:ok, %{index: nil}, state} ->
{:reply, {:error, :missing}, state}
{:ok, entry, state} ->
{:reply, {:ok, query_neighbors(entry, query_label, query_vector, limit)}, state}
{:missing, state} ->
{:reply, {:error, :missing}, state}
end
end
def handle_call({:duplicate_pairs, project_id, entries, threshold, opts}, _from, state) do
case ensure_loaded(state, project_id) do
{:ok, %{index: nil}, state} ->
{:reply, {:error, :missing}, state}
{:ok, entry, state} ->
{:reply, {:ok, scan_duplicates(entry, entries, threshold, opts)}, state}
{:missing, state} ->
{:reply, {:error, :missing}, state}
end
end
def handle_call({:flush, project_id}, _from, state) do
{:reply, :ok, save_now(state, project_id)}
end
def handle_call(:flush_all, _from, state) do
state = Enum.reduce(Map.keys(state), state, &save_now(&2, &1))
{:reply, :ok, state}
end
def handle_call({:forget, project_id}, _from, state) do
case Map.get(state, project_id) do
%{timer: timer} when is_reference(timer) -> Process.cancel_timer(timer)
_other -> :ok
end
{:reply, :ok, Map.delete(state, project_id)}
end
@impl true
def handle_info({:save, project_id}, state) do
{:noreply, save_now(state, project_id)}
end
def handle_info(_message, state), do: {:noreply, state}
@impl true
def terminate(_reason, state) do
Enum.each(Map.keys(state), &save_now(state, &1))
:ok
end
# ─── Build / query ──────────────────────────────────────────
defp build_entry(dimensions, []), do: %{index: nil, labels: %{}, dim: dimensions, timer: nil}
defp build_entry(dimensions, entries) do
count = length(entries)
{:ok, index} = HNSWLib.Index.new(@space, dimensions, count, m: @m, ef_construction: @ef_construction)
:ok = HNSWLib.Index.set_ef(index, @ef_search)
tensor =
entries
|> Enum.map(& &1.vector)
|> IO.iodata_to_binary()
|> Nx.from_binary(:f32)
|> Nx.reshape({count, dimensions})
:ok = HNSWLib.Index.add_items(index, tensor, ids: Enum.map(entries, & &1.label))
{key.post_id,
%{ %{
"label" => key.label, index: index,
"content_hash" => key.content_hash, labels: Map.new(entries, &{&1.label, &1.post_id}),
"neighbors" => neighbor_entries(keys, key, vector) dim: dimensions,
}} timer: nil
end)
|> Map.new()
payload = %{
"project_id" => project_id,
"model_id" => model_id,
"dimensions" => dimensions,
"updated_at" => Persistence.now_ms(),
"entries" => entries
} }
write_snapshot(path(project_id), payload, project_id)
end end
def read(project_id) when is_binary(project_id) do defp query_neighbors(%{index: index, labels: labels}, query_label, query_vector, limit) do
project_id case query(index, query_vector, limit + 1) do
|> candidate_paths() [] ->
|> read_snapshot_paths() []
end
def neighbors(project_id, post_id, limit) when is_binary(project_id) and is_binary(post_id) do results ->
with {:ok, snapshot} <- read(project_id), results
%{} = entry <- get_in(snapshot, ["entries", post_id]) do |> Enum.reject(fn {label, _score} -> label == query_label end)
entry |> Enum.map(fn {label, score} -> %{post_id: Map.get(labels, label), score: score} end)
|> Map.get("neighbors", []) |> Enum.reject(&is_nil(&1.post_id))
|> Enum.take(max(limit, 0)) |> Enum.take(max(limit, 0))
|> Enum.map(fn neighbor ->
%{
post_id: neighbor["post_id"],
score: neighbor["score"]
}
end)
|> then(&{:ok, &1})
else
_ -> {:error, :missing}
end end
end end
def duplicate_pairs(project_id, threshold, opts \\ []) when is_binary(project_id) do defp scan_duplicates(%{index: index, labels: labels}, entries, threshold, opts) do
with {:ok, snapshot} <- read(project_id) do on_progress = ProgressReporter.callback(opts)
entries = Map.get(snapshot, "entries", %{}) total = length(entries)
entry_count = map_size(entries) :ok = report_scan_started(on_progress, total, "embedding entries")
on_progress = progress_callback(opts)
:ok = report_scan_started(on_progress, entry_count, "embedding entries")
pairs =
entries entries
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.flat_map(fn {{post_id, entry}, index} -> |> Enum.flat_map(fn {entry, position} ->
:ok = report_scan_progress(on_progress, index, entry_count, "embedding entries") :ok = report_scan_progress(on_progress, position, total, "embedding entries")
entry index
|> Map.get("neighbors", []) |> query(entry.vector, @neighbor_limit)
|> Enum.filter(&(&1["score"] >= threshold)) |> Enum.reject(fn {label, _score} -> label == entry.label end)
|> Enum.map(fn neighbor -> |> Enum.map(fn {label, score} -> {Map.get(labels, label), score} end)
{post_id_a, post_id_b} = sort_pair(post_id, neighbor["post_id"]) |> Enum.filter(fn {post_id, score} -> not is_nil(post_id) and score >= threshold end)
|> Enum.map(fn {other_post_id, score} ->
{{post_id_a, post_id_b}, {post_id_a, post_id_b} = sort_pair(entry.post_id, other_post_id)
%{ {{post_id_a, post_id_b}, %{post_id_a: post_id_a, post_id_b: post_id_b, score: score}}
post_id_a: post_id_a,
post_id_b: post_id_b,
score: neighbor["score"]
}}
end) end)
end) end)
|> Map.new() |> Map.new()
|> Map.values() |> Map.values()
|> Enum.sort_by(& &1.score, :desc) |> Enum.sort_by(& &1.score, :desc)
end
{:ok, pairs} # Runs a knn query and returns [{label, similarity}] sorted by descending
else # similarity. Cosine distance is converted to similarity as max(0, 1 - d).
_ -> {:error, :missing} defp query(index, query_vector, k) do
case HNSWLib.Index.get_current_count(index) do
{:ok, count} when count > 0 ->
clamped = min(k, count)
case HNSWLib.Index.knn_query(index, query_vector, k: clamped) do
{:ok, labels, distances} ->
Enum.zip(
Nx.to_flat_list(labels),
Enum.map(Nx.to_flat_list(distances), fn distance -> max(0.0, 1.0 - distance) end)
)
{:error, _reason} ->
[]
end
_other ->
[]
end end
end end
defp neighbor_entries(keys, current_key, current_vector) do # ─── Persistence ────────────────────────────────────────────
keys
|> Enum.reject(&(&1.post_id == current_key.post_id)) defp schedule_save(state, project_id) do
|> Enum.map(fn other_key -> entry = Map.fetch!(state, project_id)
%{ if is_reference(entry.timer), do: Process.cancel_timer(entry.timer)
"post_id" => other_key.post_id, timer = Process.send_after(self(), {:save, project_id}, @debounce_ms)
"label" => other_key.label, Map.put(state, project_id, %{entry | timer: timer})
"score" => cosine_similarity(current_vector, decode_vector(other_key.vector))
}
end)
|> Enum.sort_by(& &1["score"], :desc)
|> Enum.take(@neighbor_limit)
end end
defp write_snapshot(snapshot_path, payload, project_id) do defp save_now(state, project_id) do
:ok = Persistence.atomic_write(snapshot_path, Jason.encode!(payload)) case Map.get(state, project_id) do
legacy_path = legacy_path(snapshot_path) nil ->
state
if File.exists?(legacy_path) do entry ->
File.rm(legacy_path) if is_reference(entry.timer), do: Process.cancel_timer(entry.timer)
persist(project_id, entry)
Map.put(state, project_id, %{entry | timer: nil})
end
end end
cleanup_legacy_project_snapshots(project_id, snapshot_path) defp persist(_project_id, %{index: nil}), do: :ok
defp persist(project_id, %{index: index, labels: labels, dim: dim}) do
index_path = path(project_id)
File.mkdir_p!(Path.dirname(index_path))
HNSWLib.Index.save_index(index, index_path)
write_meta(index_path, dim, labels)
:ok :ok
rescue
_exception -> :ok
end end
defp candidate_paths(project_id) do defp write_meta(index_path, dim, labels) do
current_snapshot_path = path(project_id) payload = %{
legacy_project_snapshot_path = legacy_project_snapshot_path(project_id) "dim" => dim,
"labels" => Enum.map(labels, fn {label, post_id} -> [label, post_id] end)
}
[ File.write(meta_path(index_path), Jason.encode!(payload))
current_snapshot_path,
legacy_path(current_snapshot_path),
legacy_project_snapshot_path,
legacy_project_snapshot_path && legacy_path(legacy_project_snapshot_path)
]
|> Enum.filter(&is_binary/1)
|> Enum.uniq()
end end
defp read_snapshot_paths([]), do: {:error, :missing} defp ensure_loaded(state, project_id) do
case Map.get(state, project_id) do
nil ->
case load_from_disk(project_id) do
{:ok, entry} -> {:ok, entry, Map.put(state, project_id, entry)}
:error -> {:missing, state}
end
defp read_snapshot_paths([snapshot_path | rest]) do entry ->
case File.read(snapshot_path) do {:ok, entry, state}
{:ok, contents} -> {:ok, Jason.decode!(contents)}
{:error, :enoent} -> read_snapshot_paths(rest)
{:error, reason} -> {:error, reason}
end end
end end
defp cleanup_legacy_project_snapshots(project_id, snapshot_path) do defp load_from_disk(project_id) do
current_paths = [snapshot_path, legacy_path(snapshot_path)] index_path = path(project_id)
project_id with {:ok, %{dim: dim, labels: labels}} <- read_meta(index_path),
|> legacy_project_snapshot_path() true <- File.exists?(index_path),
|> then(fn legacy_snapshot_path -> {:ok, index} <- HNSWLib.Index.load_index(@space, dim, index_path) do
[legacy_snapshot_path, legacy_snapshot_path && legacy_path(legacy_snapshot_path)] :ok = HNSWLib.Index.set_ef(index, @ef_search)
end) {:ok, %{index: index, labels: labels, dim: dim, timer: nil}}
|> Enum.filter(&is_binary/1) else
|> Enum.reject(&(&1 in current_paths)) _other -> :error
|> Enum.each(fn legacy_snapshot_path ->
if File.exists?(legacy_snapshot_path) do
File.rm(legacy_snapshot_path)
end end
end) rescue
_exception -> :error
end end
defp legacy_project_snapshot_path(project_id) do defp read_meta(index_path) do
case Projects.get_project(project_id) do with {:ok, contents} <- File.read(meta_path(index_path)),
nil -> nil {:ok, %{"dim" => dim, "labels" => labels}} <- Jason.decode(contents) do
project -> Path.join(Projects.project_data_dir(project), "embeddings.usearch") {:ok,
%{
dim: dim,
labels: Map.new(labels, fn [label, post_id] -> {label, post_id} end)
}}
else
_other -> :error
end end
end end
defp legacy_path(snapshot_path) do defp meta_path(index_path), do: index_path <> ".meta.json"
Path.join(Path.dirname(snapshot_path), "embeddings.index.json")
end
defp decode_vector(nil), do: []
defp decode_vector(vector), do: Jason.decode!(vector)
defp cosine_similarity([], _other), do: 0.0
defp cosine_similarity(_vector, []), do: 0.0
defp cosine_similarity(left, right) do
Enum.zip(left, right)
|> Enum.reduce(0.0, fn {left_value, right_value}, acc -> acc + left_value * right_value end)
|> max(0.0)
end
defp sort_pair(post_id_a, post_id_b) when post_id_a <= post_id_b, do: {post_id_a, post_id_b} defp sort_pair(post_id_a, post_id_b) when post_id_a <= post_id_b, do: {post_id_a, post_id_b}
defp sort_pair(post_id_a, post_id_b), do: {post_id_b, post_id_a} defp sort_pair(post_id_a, post_id_b), do: {post_id_b, post_id_a}
defp progress_callback(opts), do: ProgressReporter.callback(opts)
defp report_scan_started(callback, total, label) do defp report_scan_started(callback, total, label) do
ProgressReporter.report_count_started(callback, total, label, ProgressReporter.report_count_started(callback, total, label,
verb: "Scanning", verb: "Scanning",

View File

@@ -12,7 +12,9 @@ defmodule BDS.Embeddings.Key do
belongs_to :project, BDS.Projects.Project, type: :string belongs_to :project, BDS.Projects.Project, type: :string
field :content_hash, :string field :content_hash, :string
field :vector, :string # Packed little-endian Float32 BLOB (dimensions * 4 bytes), per the
# VectorCacheInDb invariant in specs/embedding.allium.
field :vector, :binary
end end
def changeset(key, attrs) do def changeset(key, attrs) do

View File

@@ -5,6 +5,8 @@ defmodule BDS.Generation.Outputs do
import BDS.Generation.Renderers import BDS.Generation.Renderers
import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1] import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1]
alias BDS.Rendering.TemplateSelection
@spec additional_languages(map()) :: [String.t()] @spec additional_languages(map()) :: [String.t()]
def additional_languages(plan) do def additional_languages(plan) do
Enum.reject(plan.blog_languages, &(&1 == plan.language)) Enum.reject(plan.blog_languages, &(&1 == plan.language))
@@ -391,10 +393,12 @@ defmodule BDS.Generation.Outputs do
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post) canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content) body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
effective_slug = effective_template_slug(project_id, post)
{page_output_path(post.slug, nil), {page_output_path(post.slug, nil),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: canonical_variant.id, id: canonical_variant.id,
title: canonical_variant.title, title: canonical_variant.title,
@@ -423,10 +427,12 @@ defmodule BDS.Generation.Outputs do
|> Enum.map(fn post -> |> Enum.map(fn post ->
body = load_body(project_id, post.file_path, post.content) body = load_body(project_id, post.file_path, post.content)
effective_slug = effective_template_slug(project_id, post)
{page_output_path(post.slug, language), {page_output_path(post.slug, language),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: post.id, id: post.id,
title: post.title, title: post.title,
@@ -521,10 +527,12 @@ defmodule BDS.Generation.Outputs do
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post) canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content) body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
effective_slug = effective_template_slug(project_id, post)
{post_output_path(post), {post_output_path(post),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: canonical_variant.id, id: canonical_variant.id,
title: canonical_variant.title, title: canonical_variant.title,
@@ -551,10 +559,12 @@ defmodule BDS.Generation.Outputs do
Enum.map(posts, fn post -> Enum.map(posts, fn post ->
body = load_body(project_id, post.file_path, post.content) body = load_body(project_id, post.file_path, post.content)
effective_slug = effective_template_slug(project_id, post)
{post_output_path(post, language), {post_output_path(post, language),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: post.id, id: post.id,
title: post.title, title: post.title,
@@ -571,4 +581,18 @@ defmodule BDS.Generation.Outputs do
post_outputs ++ translation_outputs post_outputs ++ translation_outputs
end end
defp effective_template_slug(project_id, post) do
slug = Map.get(post, :template_slug)
if is_binary(slug) and slug != "" do
slug
else
TemplateSelection.resolve_post_template_slug(
project_id,
Map.get(post, :tags) || [],
Map.get(post, :categories) || []
)
end
end
end end

View File

@@ -7,10 +7,25 @@ defmodule BDS.Generation.Pagefind do
@typedoc "A (relative_path, content) generated file tuple." @typedoc "A (relative_path, content) generated file tuple."
@type generated_file :: {String.t(), String.t()} @type generated_file :: {String.t(), String.t()}
@assets_dir Application.app_dir(:bds, "priv/preview_assets/assets")
@ui_js_path Path.join(@assets_dir, "pagefind-ui.js")
@ui_css_path Path.join(@assets_dir, "pagefind-ui.css")
@external_resource @ui_js_path
@external_resource @ui_css_path
@ui_js File.read!(@ui_js_path)
@ui_css File.read!(@ui_css_path)
@doc """ @doc """
Build the per-language Pagefind index outputs (`pagefind/index.json`, Build the per-language Pagefind index outputs (`pagefind/index.json`,
`pagefind/pagefind-ui.js`, `pagefind/pagefind-ui.css`) for every blog `pagefind/pagefind-ui.js`, `pagefind/pagefind-ui.css`) for every blog
language declared on the plan. language declared on the plan.
The fragment index records one entry per indexable page, where indexable
means the page carries a `data-pagefind-body` region. Each entry stores the
page URL, its title, and the body text scoped to that region — mirroring
Pagefind's behaviour of ignoring content outside `data-pagefind-body`.
""" """
@spec build_outputs(map(), [html_output()]) :: [generated_file()] @spec build_outputs(map(), [html_output()]) :: [generated_file()]
def build_outputs(plan, html_outputs) do def build_outputs(plan, html_outputs) do
@@ -31,8 +46,8 @@ defmodule BDS.Generation.Pagefind do
[ [
{Path.join(prefix ++ ["index.json"]), {Path.join(prefix ++ ["index.json"]),
Jason.encode!(%{"language" => language, "pages" => pages})}, Jason.encode!(%{"language" => language, "pages" => pages})},
{Path.join(prefix ++ ["pagefind-ui.js"]), ui_js(language)}, {Path.join(prefix ++ ["pagefind-ui.js"]), @ui_js},
{Path.join(prefix ++ ["pagefind-ui.css"]), ui_css()} {Path.join(prefix ++ ["pagefind-ui.css"]), @ui_css}
] ]
end) end)
end end
@@ -43,11 +58,14 @@ defmodule BDS.Generation.Pagefind do
String.ends_with?(relative_path, ".html") and String.ends_with?(relative_path, ".html") and
language_match?(relative_path, route_language, other_prefixes) language_match?(relative_path, route_language, other_prefixes)
end) end)
|> Enum.map(fn {relative_path, content} -> |> Enum.flat_map(fn {relative_path, content} ->
%{ case body_text(content) do
"url" => "/" <> relative_path, nil ->
"text" => text(content) []
}
text ->
[%{"url" => "/" <> relative_path, "title" => title(content), "text" => text}]
end
end) end)
end end
@@ -60,19 +78,94 @@ defmodule BDS.Generation.Pagefind do
defp language_match?(relative_path, route_language, _other_prefixes), defp language_match?(relative_path, route_language, _other_prefixes),
do: String.starts_with?(relative_path, route_language <> "/") do: String.starts_with?(relative_path, route_language <> "/")
defp text(content) do # Extract the indexable body text scoped to the data-pagefind-body element.
content # Returns nil when the page is not marked, so unmarked pages are excluded
# from the index entirely (matching Pagefind semantics).
defp body_text(content) do
case Regex.run(~r/<([a-zA-Z0-9]+)[^>]*\bdata-pagefind-body\b[^>]*>/, content,
return: :index
) do
[{open_start, open_len}, {tag_start, tag_len}] ->
tag = binary_part(content, tag_start, tag_len)
region = scoped_region(content, tag, open_start + open_len)
plain_text(region)
_no_match ->
nil
end
end
# Capture the inner HTML of the marked element by balancing same-tag
# open/close pairs from the opening tag onward.
defp scoped_region(content, tag, body_start) do
rest = binary_part(content, body_start, byte_size(content) - body_start)
open_re = Regex.compile!("<#{tag}\\b", "i")
close_re = Regex.compile!("</#{tag}\\s*>", "i")
events =
(Regex.scan(open_re, rest, return: :index) ++ Regex.scan(close_re, rest, return: :index))
|> Enum.map(fn [{pos, _len}] -> pos end)
|> Enum.map(fn pos -> {pos, event_kind(rest, pos, tag)} end)
|> Enum.sort_by(&elem(&1, 0))
close_at = balanced_close(events, 0)
case close_at do
nil -> rest
pos -> binary_part(rest, 0, pos)
end
end
defp event_kind(rest, pos, tag) do
if String.starts_with?(binary_part(rest, pos, min(2 + byte_size(tag), byte_size(rest) - pos)), "</") do
:close
else
:open
end
end
defp balanced_close([], _depth), do: nil
defp balanced_close([{pos, :close} | _rest], 0), do: pos
defp balanced_close([{_pos, :close} | rest], depth),
do: balanced_close(rest, depth - 1)
defp balanced_close([{_pos, :open} | rest], depth),
do: balanced_close(rest, depth + 1)
defp title(content) do
tag_text(content, ~r/<title[^>]*>(.*?)<\/title>/si) ||
tag_text(content, ~r/<h1[^>]*>(.*?)<\/h1>/si) ||
""
end
defp tag_text(content, regex) do
case Regex.run(regex, content) do
[_full, raw] -> raw |> plain_text() |> nil_if_blank()
_no_match -> nil
end
end
defp nil_if_blank(""), do: nil
defp nil_if_blank(value), do: value
defp plain_text(html) do
html
|> String.replace(~r/<[^>]+>/, " ") |> String.replace(~r/<[^>]+>/, " ")
|> decode_entities()
|> String.replace(~r/\s+/u, " ") |> String.replace(~r/\s+/u, " ")
|> String.trim() |> String.trim()
end end
defp ui_js(language) do defp decode_entities(text) do
"window.bDSPagefind = { language: #{Jason.encode!(language)} };\n" text
end |> String.replace("&amp;", "&")
|> String.replace("&lt;", "<")
defp ui_css do |> String.replace("&gt;", ">")
".pagefind-ui{display:block;}\n" |> String.replace("&quot;", "\"")
|> String.replace("&#39;", "'")
|> String.replace("&nbsp;", " ")
end end
defp route_language(main_language, language) when main_language == language, do: nil defp route_language(main_language, language) when main_language == language, do: nil

View File

@@ -114,10 +114,19 @@ defmodule BDS.Git do
def history(project_id, branch, opts \\ []) def history(project_id, branch, opts \\ [])
when is_binary(project_id) and is_binary(branch) and is_list(opts) do when is_binary(project_id) and is_binary(branch) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id), with {:ok, project_dir} <- project_dir(project_id),
{:ok, local_log} <- run_git(project_dir, ["log", "--format=%H%x09%s", branch], opts), {:ok, local_log} <-
{:ok, remote_log} <- run_git(
run_git(project_dir, ["log", "--format=%H", "origin/#{branch}"], opts) do project_dir,
local_commits = parse_local_history(local_log) ["log", "--date=short", "--format=%H%x09%an%x09%ad%x09%s", branch],
opts
) do
remote_log =
case run_git(project_dir, ["log", "--format=%H", "origin/#{branch}"], opts) do
{:ok, output} -> output
{:error, {:git_failed, _message}} -> ""
end
local_commits = parse_history_log(local_log)
remote_hashes = MapSet.new(parse_remote_history(remote_log)) remote_hashes = MapSet.new(parse_remote_history(remote_log))
local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash)) local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash))
@@ -126,7 +135,7 @@ defmodule BDS.Git do
|> MapSet.difference(local_hashes) |> MapSet.difference(local_hashes)
|> MapSet.to_list() |> MapSet.to_list()
|> Enum.map(fn hash -> |> Enum.map(fn hash ->
%{hash: hash, subject: nil, sync_status: %{kind: :remote_only}} %{hash: hash, subject: nil, author: nil, date: nil, sync_status: %{kind: :remote_only}}
end) end)
commits = commits =
@@ -204,6 +213,22 @@ defmodule BDS.Git do
end end
end end
def set_remote(project_id, remote_url, opts \\ [])
when is_binary(project_id) and is_binary(remote_url) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id) do
case run_git(project_dir, ["remote", "add", "origin", remote_url], opts) do
{:ok, _output} ->
{:ok, %{remote_url: remote_url}}
{:error, {:git_failed, _message}} ->
with {:ok, _output} <-
run_git(project_dir, ["remote", "set-url", "origin", remote_url], opts) do
{:ok, %{remote_url: remote_url}}
end
end
end
end
def remote_state(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do def remote_state(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id), with {:ok, project_dir} <- project_dir(project_id),
{:ok, local_branch} <- current_branch(project_dir, opts) do {:ok, local_branch} <- current_branch(project_dir, opts) do
@@ -380,6 +405,23 @@ defmodule BDS.Git do
end) end)
end end
defp parse_history_log(output) do
output
|> String.split("\n", trim: true)
|> Enum.map(fn line ->
case String.split(line, "\t", parts: 4) do
[hash, author, date, subject] ->
%{hash: hash, author: author, date: date, subject: subject}
[hash, author, date] ->
%{hash: hash, author: author, date: date, subject: nil}
[hash | _rest] ->
%{hash: hash, author: nil, date: nil, subject: nil}
end
end)
end
defp parse_remote_history(output) do defp parse_remote_history(output) do
String.split(output, "\n", trim: true) String.split(output, "\n", trim: true)
end end

View File

@@ -101,11 +101,18 @@ defmodule BDS.Maintenance.Repair do
:file_to_db -> :file_to_db ->
post_ids = Enum.map(items, &metadata_diff_item_entity_id/1) post_ids = Enum.map(items, &metadata_diff_item_entity_id/1)
{:ok, repaired_post_ids} = Embeddings.repair_posts(project_id, post_ids) # If the embedding model is unavailable, every item is reported as
repaired_post_ids = MapSet.new(repaired_post_ids) # failed rather than crashing the repair task.
repaired =
case Embeddings.repair_posts(project_id, post_ids) do
{:ok, repaired_post_ids} -> repaired_post_ids
{:error, _reason} -> []
end
repaired_set = MapSet.new(repaired)
build_batch_repair_result(items, total, on_progress, fn item -> build_batch_repair_result(items, total, on_progress, fn item ->
MapSet.member?(repaired_post_ids, metadata_diff_item_entity_id(item)) MapSet.member?(repaired_set, metadata_diff_item_entity_id(item))
end) end)
:db_to_file -> :db_to_file ->

View File

@@ -1,6 +1,8 @@
defmodule BDS.Metadata do defmodule BDS.Metadata do
@moduledoc false @moduledoc false
require Logger
alias BDS.Embeddings alias BDS.Embeddings
alias BDS.I18n alias BDS.I18n
alias BDS.Persistence alias BDS.Persistence
@@ -13,6 +15,9 @@ defmodule BDS.Metadata do
@default_categories ["article", "aside", "page", "picture"] @default_categories ["article", "aside", "page", "picture"]
@min_posts_per_page 1 @min_posts_per_page 1
@max_posts_per_page 500 @max_posts_per_page 500
@default_image_import_concurrency 4
@min_image_import_concurrency 1
@max_image_import_concurrency 8
@supported_pico_themes MapSet.new([ @supported_pico_themes MapSet.new([
"default", "default",
"amber", "amber",
@@ -70,6 +75,7 @@ defmodule BDS.Metadata do
:main_language, :main_language,
:default_author, :default_author,
:max_posts_per_page, :max_posts_per_page,
:image_import_concurrency,
:blogmark_category, :blogmark_category,
:pico_theme, :pico_theme,
:semantic_similarity_enabled, :semantic_similarity_enabled,
@@ -238,6 +244,8 @@ defmodule BDS.Metadata do
default_author: Map.get(project_metadata, "default_author"), default_author: Map.get(project_metadata, "default_author"),
max_posts_per_page: max_posts_per_page:
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page), Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
image_import_concurrency:
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
blogmark_category: Map.get(project_metadata, "blogmark_category"), blogmark_category: Map.get(project_metadata, "blogmark_category"),
pico_theme: Map.get(project_metadata, "pico_theme"), pico_theme: Map.get(project_metadata, "pico_theme"),
semantic_similarity_enabled: semantic_similarity_enabled:
@@ -274,6 +282,8 @@ defmodule BDS.Metadata do
default_author: Map.get(project_metadata, "default_author"), default_author: Map.get(project_metadata, "default_author"),
max_posts_per_page: max_posts_per_page:
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page), Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
image_import_concurrency:
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
blogmark_category: Map.get(project_metadata, "blogmark_category"), blogmark_category: Map.get(project_metadata, "blogmark_category"),
pico_theme: Map.get(project_metadata, "pico_theme"), pico_theme: Map.get(project_metadata, "pico_theme"),
semantic_similarity_enabled: semantic_similarity_enabled:
@@ -293,6 +303,7 @@ defmodule BDS.Metadata do
main_language: nil, main_language: nil,
default_author: nil, default_author: nil,
max_posts_per_page: @default_max_posts_per_page, max_posts_per_page: @default_max_posts_per_page,
image_import_concurrency: @default_image_import_concurrency,
blogmark_category: nil, blogmark_category: nil,
pico_theme: nil, pico_theme: nil,
semantic_similarity_enabled: false, semantic_similarity_enabled: false,
@@ -308,6 +319,8 @@ defmodule BDS.Metadata do
main_language: normalize_optional_language(attr(attrs, :main_language)), main_language: normalize_optional_language(attr(attrs, :main_language)),
default_author: attr(attrs, :default_author), default_author: attr(attrs, :default_author),
max_posts_per_page: normalize_posts_per_page(attr(attrs, :max_posts_per_page)), max_posts_per_page: normalize_posts_per_page(attr(attrs, :max_posts_per_page)),
image_import_concurrency:
normalize_image_import_concurrency(attr(attrs, :image_import_concurrency)),
blogmark_category: attr(attrs, :blogmark_category), blogmark_category: attr(attrs, :blogmark_category),
pico_theme: normalize_pico_theme(attr(attrs, :pico_theme)), pico_theme: normalize_pico_theme(attr(attrs, :pico_theme)),
semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false, semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false,
@@ -342,6 +355,7 @@ defmodule BDS.Metadata do
"main_language" => project_metadata.main_language, "main_language" => project_metadata.main_language,
"default_author" => project_metadata.default_author, "default_author" => project_metadata.default_author,
"max_posts_per_page" => project_metadata.max_posts_per_page, "max_posts_per_page" => project_metadata.max_posts_per_page,
"image_import_concurrency" => project_metadata.image_import_concurrency,
"blogmark_category" => project_metadata.blogmark_category, "blogmark_category" => project_metadata.blogmark_category,
"pico_theme" => project_metadata.pico_theme, "pico_theme" => project_metadata.pico_theme,
"semantic_similarity_enabled" => project_metadata.semantic_similarity_enabled, "semantic_similarity_enabled" => project_metadata.semantic_similarity_enabled,
@@ -429,6 +443,8 @@ defmodule BDS.Metadata do
"main_language" => Map.get(payload, "mainLanguage"), "main_language" => Map.get(payload, "mainLanguage"),
"default_author" => Map.get(payload, "defaultAuthor"), "default_author" => Map.get(payload, "defaultAuthor"),
"max_posts_per_page" => Map.get(payload, "maxPostsPerPage", @default_max_posts_per_page), "max_posts_per_page" => Map.get(payload, "maxPostsPerPage", @default_max_posts_per_page),
"image_import_concurrency" =>
Map.get(payload, "imageImportConcurrency", @default_image_import_concurrency),
"blogmark_category" => Map.get(payload, "blogmarkCategory"), "blogmark_category" => Map.get(payload, "blogmarkCategory"),
"pico_theme" => Map.get(payload, "picoTheme"), "pico_theme" => Map.get(payload, "picoTheme"),
"semantic_similarity_enabled" => Map.get(payload, "semanticSimilarityEnabled", false), "semantic_similarity_enabled" => Map.get(payload, "semanticSimilarityEnabled", false),
@@ -505,6 +521,8 @@ defmodule BDS.Metadata do
"defaultAuthor" => Map.get(project_metadata, "default_author"), "defaultAuthor" => Map.get(project_metadata, "default_author"),
"maxPostsPerPage" => "maxPostsPerPage" =>
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page), Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
"imageImportConcurrency" =>
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
"blogmarkCategory" => Map.get(project_metadata, "blogmark_category"), "blogmarkCategory" => Map.get(project_metadata, "blogmark_category"),
"picoTheme" => Map.get(project_metadata, "pico_theme"), "picoTheme" => Map.get(project_metadata, "pico_theme"),
"semanticSimilarityEnabled" => "semanticSimilarityEnabled" =>
@@ -576,6 +594,23 @@ defmodule BDS.Metadata do
defp normalize_posts_per_page(_value), do: @default_max_posts_per_page defp normalize_posts_per_page(_value), do: @default_max_posts_per_page
defp normalize_image_import_concurrency(nil), do: @default_image_import_concurrency
defp normalize_image_import_concurrency(value) when is_integer(value) do
value
|> max(@min_image_import_concurrency)
|> min(@max_image_import_concurrency)
end
defp normalize_image_import_concurrency(value) when is_binary(value) do
case Integer.parse(String.trim(value)) do
{integer, ""} -> normalize_image_import_concurrency(integer)
_ -> @default_image_import_concurrency
end
end
defp normalize_image_import_concurrency(_value), do: @default_image_import_concurrency
defp normalize_optional_language(nil), do: nil defp normalize_optional_language(nil), do: nil
defp normalize_optional_language(""), do: nil defp normalize_optional_language(""), do: nil
@@ -620,7 +655,17 @@ defmodule BDS.Metadata do
) do ) do
if previous_state.semantic_similarity_enabled != true and if previous_state.semantic_similarity_enabled != true and
project_metadata.semantic_similarity_enabled == true do project_metadata.semantic_similarity_enabled == true do
{:ok, _indexed_post_ids} = Embeddings.index_unindexed(project_id) # Backfill is best-effort: if the embedding model is unavailable, keep the
# setting enabled and log it rather than failing the metadata update.
case Embeddings.index_unindexed(project_id) do
{:ok, _indexed_post_ids} ->
:ok
{:error, reason} ->
Logger.warning(
"Embedding backfill skipped for project #{project_id}: #{inspect(reason)}"
)
end
end end
result result

View File

@@ -171,6 +171,10 @@ defmodule BDS.Posts do
serialize_post_file(%{post | updated_at: updated_at, content: body}, published_at) serialize_post_file(%{post | updated_at: updated_at, content: body}, published_at)
) )
if post.file_path != "" and post.file_path != relative_path do
delete_post_file(post)
end
post post
|> Post.changeset(%{ |> Post.changeset(%{
status: :published, status: :published,
@@ -309,8 +313,11 @@ defmodule BDS.Posts do
select: pm.media_id select: pm.media_id
) )
{:ok, translations} = Translations.list_post_translations(post.id)
case Repo.delete(post) do case Repo.delete(post) do
{:ok, deleted_post} -> {:ok, deleted_post} ->
Enum.each(translations, &FileSync.delete_translation_file/1)
delete_post_file(deleted_post) delete_post_file(deleted_post)
Embeddings.remove_post(deleted_post.id) Embeddings.remove_post(deleted_post.id)
PostLinks.delete_post_links(deleted_post.id) PostLinks.delete_post_links(deleted_post.id)
@@ -352,6 +359,36 @@ defmodule BDS.Posts do
end end
end end
@spec unarchive_post(String.t()) ::
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
def unarchive_post(post_id) do
case Repo.get(Post, post_id) do
nil ->
{:error, :not_found}
%Post{status: :archived} = post ->
content = restore_content_for_unarchive(post)
post
|> Post.changeset(%{status: :draft, content: content, updated_at: Persistence.now_ms()})
|> Repo.update()
|> case do
{:ok, updated_post} ->
:ok = Search.sync_post(updated_post)
{:ok, updated_post}
error ->
error
end
%Post{} = post ->
{:error,
post
|> Post.changeset(%{})
|> Ecto.Changeset.add_error(:status, "cannot unarchive non-archived post")}
end
end
@spec get_post!(String.t()) :: Post.t() @spec get_post!(String.t()) :: Post.t()
@spec get_post(String.t()) :: Post.t() | nil @spec get_post(String.t()) :: Post.t() | nil
def get_post(post_id), do: Repo.get(Post, post_id) def get_post(post_id), do: Repo.get(Post, post_id)
@@ -581,6 +618,17 @@ defmodule BDS.Posts do
) )
end end
defp restore_content_for_unarchive(%Post{content: content}) when is_binary(content), do: content
defp restore_content_for_unarchive(%Post{file_path: file_path} = post)
when file_path not in [nil, ""] do
project = Projects.get_project!(post.project_id)
full_path = Path.join(Projects.project_data_dir(project), file_path)
read_markdown_body(full_path)
end
defp restore_content_for_unarchive(_post), do: ""
defp normalize_title(nil), do: "" defp normalize_title(nil), do: ""
defp normalize_title(title), do: title defp normalize_title(title), do: title

View File

@@ -75,7 +75,7 @@ defmodule BDS.Posts.FileSync do
{"status", :published}, {"status", :published},
{"author", post.author}, {"author", post.author},
{"language", post.language}, {"language", post.language},
{"doNotTranslate", post.do_not_translate}, {"doNotTranslate", post.do_not_translate || nil},
{"templateSlug", post.template_slug}, {"templateSlug", post.template_slug},
{"createdAt", post.created_at}, {"createdAt", post.created_at},
{"updatedAt", post.updated_at}, {"updatedAt", post.updated_at},

View File

@@ -9,10 +9,15 @@ defmodule BDS.Preview do
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
alias BDS.Rendering alias BDS.Rendering
alias BDS.Rendering.TemplateSelection
@host "127.0.0.1" @host "127.0.0.1"
@port 4123 @port 4123
# Max time to wait for inflight requests to finish during graceful shutdown
# before remaining request tasks are forcibly terminated.
@drain_timeout 5_000
def start_link(_opts) do def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__) GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end end
@@ -55,7 +60,7 @@ defmodule BDS.Preview do
@impl true @impl true
def init(_state) do def init(_state) do
{:ok, %{current: nil}} {:ok, %{current: nil, stopping: nil}}
end end
@impl true @impl true
@@ -77,15 +82,12 @@ defmodule BDS.Preview do
{:reply, reply, next_state} {:reply, reply, next_state}
end end
def handle_call({:stop_preview, project_id}, _from, state) do def handle_call({:stop_preview, project_id}, from, state) do
next_state =
if match?(%{project_id: ^project_id}, state.current) do if match?(%{project_id: ^project_id}, state.current) do
stop_current_server(state) begin_graceful_stop(state, from)
else else
state {:reply, :ok, state}
end end
{:reply, :ok, next_state}
end end
def handle_call({:request, project_id, request_path, query_params}, _from, state) do def handle_call({:request, project_id, request_path, query_params}, _from, state) do
@@ -140,6 +142,25 @@ defmodule BDS.Preview do
end end
@impl true @impl true
def handle_cast({:track_request, pid}, %{current: %{} = current} = state) when is_pid(pid) do
ref = Process.monitor(pid)
inflight = Map.put(current.inflight, ref, pid)
{:noreply, %{state | current: %{current | inflight: inflight}}}
end
def handle_cast({:track_request, _pid}, state), do: {:noreply, state}
@impl true
def handle_info({:DOWN, ref, :process, _pid, _reason}, %{current: %{} = current} = state) do
inflight = Map.delete(current.inflight, ref)
state = %{state | current: %{current | inflight: inflight}}
{:noreply, maybe_finalize_stop(state)}
end
def handle_info(:drain_timeout, state) do
{:noreply, force_finalize_stop(state)}
end
def handle_info(_msg, state) do def handle_info(_msg, state) do
{:noreply, state} {:noreply, state}
end end
@@ -154,24 +175,39 @@ defmodule BDS.Preview do
:error -> :error ->
with {:ok, relative_path, kind} <- route_request(request_path) do with {:ok, relative_path, kind} <- route_request(request_path) do
full_path =
case kind do case kind do
:media -> safe_join(server.data_dir, Path.join(["media", relative_path])) :media ->
:generated -> safe_join(Path.join(server.data_dir, "html"), relative_path) serve_file(safe_join(server.data_dir, Path.join(["media", relative_path])),
server: server, query_params: query_params)
:generated ->
case BDS.Preview.Router.render_route(server.project_id, request_path) do
{:ok, response} ->
{:ok, apply_response_overrides(response, query_params)}
:not_matched ->
serve_file(safe_join(Path.join(server.data_dir, "html"), relative_path),
server: server, query_params: query_params)
end
end
end
end
end end
case full_path do defp serve_file({:error, :not_found}, opts) do
{:error, :not_found} -> render_not_found_response(opts[:server].project_id, opts[:query_params])
{:error, :not_found} end
resolved_path -> defp serve_file(resolved_path, opts) do
case read_response(resolved_path) do case read_response(resolved_path) do
{:error, :not_found} -> render_not_found_response(server.project_id, query_params) {:error, :not_found} ->
{:ok, response} -> {:ok, apply_response_overrides(response, query_params)} render_not_found_response(opts[:server].project_id, opts[:query_params])
other -> other
end {:ok, response} ->
end {:ok, apply_response_overrides(response, opts[:query_params])}
end
other ->
other
end end
end end
@@ -204,6 +240,7 @@ defmodule BDS.Preview do
defp draft_preview_payload(post, query_params) do defp draft_preview_payload(post, query_params) do
requested_language = query_params |> Map.get("lang") |> normalize_requested_language() requested_language = query_params |> Map.get("lang") |> normalize_requested_language()
effective_slug = post.template_slug || TemplateSelection.resolve_post_template_slug(post.project_id, post.tags, post.categories)
case draft_preview_translation(post.id, requested_language, post.language) do case draft_preview_translation(post.id, requested_language, post.language) do
%Translation{} = translation -> %Translation{} = translation ->
@@ -215,7 +252,7 @@ defmodule BDS.Preview do
slug: post.slug, slug: post.slug,
language: translation.language, language: translation.language,
excerpt: translation.excerpt, excerpt: translation.excerpt,
template_slug: post.template_slug template_slug: effective_slug
} }
nil -> nil ->
@@ -227,7 +264,7 @@ defmodule BDS.Preview do
slug: post.slug, slug: post.slug,
language: post.language, language: post.language,
excerpt: post.excerpt, excerpt: post.excerpt,
template_slug: post.template_slug template_slug: effective_slug
} }
end end
end end
@@ -270,9 +307,18 @@ defmodule BDS.Preview do
defp accept_loop(listener, project_id) do defp accept_loop(listener, project_id) do
case :gen_tcp.accept(listener) do case :gen_tcp.accept(listener) do
{:ok, socket} -> {:ok, socket} ->
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn -> case Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
serve_client(socket, project_id) serve_client(socket, project_id)
end) end) do
{:ok, pid} ->
# Hand the socket to the request task so an inflight request survives
# the acceptor being shut down (it would otherwise close the socket).
_ = :gen_tcp.controlling_process(socket, pid)
GenServer.cast(__MODULE__, {:track_request, pid})
_other ->
:ok
end
accept_loop(listener, project_id) accept_loop(listener, project_id)
@@ -395,14 +441,58 @@ defmodule BDS.Preview do
end end
end end
defp stop_current_server(%{current: %{listener: listener, acceptor_pid: acceptor_pid}} = state) do # Graceful shutdown: stop accepting new connections, then wait for inflight
_ = :gen_tcp.close(listener) # request tasks to finish before reporting the server stopped. The stop call
if is_pid(acceptor_pid), do: Process.exit(acceptor_pid, :normal) # is parked (no immediate reply) and finalized from the :DOWN handlers, so the
# GenServer stays available to serve the requests it is draining.
defp begin_graceful_stop(%{current: current} = state, from) do
_ = :gen_tcp.close(current.listener)
if is_pid(current.acceptor_pid), do: Process.exit(current.acceptor_pid, :normal)
if map_size(current.inflight) == 0 do
{:reply, :ok, %{state | current: nil, stopping: nil}}
else
timer = Process.send_after(self(), :drain_timeout, @drain_timeout)
{:noreply, %{state | stopping: %{from: from, timer: timer}}}
end
end
defp maybe_finalize_stop(
%{stopping: %{from: from, timer: timer}, current: %{inflight: inflight}} = state
)
when map_size(inflight) == 0 do
if is_reference(timer), do: Process.cancel_timer(timer)
GenServer.reply(from, :ok)
%{state | current: nil, stopping: nil}
end
defp maybe_finalize_stop(state), do: state
defp force_finalize_stop(%{stopping: %{from: from}, current: %{inflight: inflight}} = state) do
kill_inflight(inflight)
GenServer.reply(from, :ok)
%{state | current: nil, stopping: nil}
end
defp force_finalize_stop(state), do: state
# Hard stop used when restarting the server in place (no graceful drain).
defp stop_current_server(%{current: %{} = current} = state) do
_ = :gen_tcp.close(current.listener)
if is_pid(current.acceptor_pid), do: Process.exit(current.acceptor_pid, :normal)
kill_inflight(current.inflight)
%{state | current: nil} %{state | current: nil}
end end
defp stop_current_server(state), do: state defp stop_current_server(state), do: state
defp kill_inflight(inflight) do
Enum.each(inflight, fn {ref, pid} ->
Process.demonitor(ref, [:flush])
if is_pid(pid), do: Process.exit(pid, :kill)
end)
end
defp start_server(state, project_id, data_dir, owner_pid) do defp start_server(state, project_id, data_dir, owner_pid) do
state = stop_current_server(state) state = stop_current_server(state)
maybe_allow_repo(owner_pid) maybe_allow_repo(owner_pid)
@@ -425,7 +515,8 @@ defmodule BDS.Preview do
port: @port, port: @port,
is_running: true, is_running: true,
listener: listener, listener: listener,
acceptor_pid: acceptor_pid acceptor_pid: acceptor_pid,
inflight: %{}
} }
{{:ok, public_server(server)}, %{state | current: server}} {{:ok, public_server(server)}, %{state | current: server}}

567
lib/bds/preview/router.ex Normal file
View File

@@ -0,0 +1,567 @@
defmodule BDS.Preview.Router do
@moduledoc false
import Ecto.Query
alias BDS.Generation.Paths
alias BDS.MapUtils
alias BDS.Metadata, as: ProjectMetadata
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.Posts.Translation
alias BDS.Rendering
alias BDS.Rendering.TemplateSelection
alias BDS.Repo
@type route ::
{:home, pos_integer()}
| {:post, String.t(), integer(), integer(), integer()}
| {:page, String.t()}
| {:category, String.t(), pos_integer()}
| {:tag, String.t(), pos_integer()}
| {:year, integer(), pos_integer()}
| {:month, integer(), integer(), pos_integer()}
| {:day, integer(), integer(), integer(), pos_integer()}
| :not_matched
@spec render_route(String.t(), String.t()) :: {:ok, map()} | :not_matched
def render_route(project_id, request_path) do
{:ok, metadata} = ProjectMetadata.get_project_metadata(project_id)
main_language = metadata.main_language || "en"
blog_languages = metadata.blog_languages || []
additional_languages = Enum.reject(blog_languages, &(&1 == main_language))
segments = String.split(request_path, "/", trim: true)
{language, route_segments} = extract_language_prefix(segments, additional_languages)
effective_language = language || main_language
case match_route(route_segments) do
:not_matched ->
:not_matched
route ->
case render(project_id, route, effective_language, main_language, metadata) do
{:ok, body} ->
{:ok, %{content_type: "text/html", body: body}}
{:error, :not_found} ->
:not_matched
end
end
end
@spec match_route([String.t()]) :: route()
def match_route([]), do: {:home, 1}
def match_route(["page", n]), do: {:home, parse_page(n)}
def match_route(["category", name]), do: {:category, URI.decode(name), 1}
def match_route(["category", name, "page", n]),
do: {:category, URI.decode(name), parse_page(n)}
def match_route(["tag", name]), do: {:tag, URI.decode(name), 1}
def match_route(["tag", name, "page", n]), do: {:tag, URI.decode(name), parse_page(n)}
def match_route([y, m, d, slug]) do
with {year, ""} <- Integer.parse(y),
{month, ""} <- Integer.parse(m),
{day, ""} <- Integer.parse(d) do
{:post, slug, year, month, day}
else
_ -> :not_matched
end
end
def match_route([y, m, d, "page", n]) do
with {year, ""} <- Integer.parse(y),
{month, ""} <- Integer.parse(m),
{day, ""} <- Integer.parse(d) do
{:day, year, month, day, parse_page(n)}
else
_ -> :not_matched
end
end
def match_route([y, m, d]) do
with {year, ""} <- Integer.parse(y),
{month, ""} <- Integer.parse(m),
{day, ""} <- Integer.parse(d) do
{:day, year, month, day, 1}
else
_ -> :not_matched
end
end
def match_route([y, m, "page", n]) do
with {year, ""} <- Integer.parse(y),
{month, ""} <- Integer.parse(m) do
{:month, year, month, parse_page(n)}
else
_ -> :not_matched
end
end
def match_route([y, m]) do
with {year, ""} <- Integer.parse(y),
{month, ""} <- Integer.parse(m) do
{:month, year, month, 1}
else
_ -> :not_matched
end
end
def match_route([y, "page", n]) do
with {year, ""} <- Integer.parse(y) do
{:year, year, parse_page(n)}
else
_ -> :not_matched
end
end
def match_route([y]) do
case Integer.parse(y) do
{year, ""} -> {:year, year, 1}
_ -> {:page, y}
end
end
def match_route(_segments), do: :not_matched
## Rendering
defp render(project_id, {:home, page_number}, language, main_language, metadata) do
posts = load_published_list_posts(project_id, metadata)
posts = maybe_resolve_language(posts, language, main_language, project_id)
render_list(project_id, posts, page_number, metadata, language, main_language, %{kind: "core"})
end
defp render(project_id, {:post, slug, year, month, day}, language, main_language, _metadata) do
case find_post_by_slug_and_date(project_id, slug, year, month, day) do
nil ->
{:error, :not_found}
post ->
render_post(project_id, post, language, main_language)
end
end
defp render(project_id, {:page, slug}, language, main_language, _metadata) do
case find_page_by_slug(project_id, slug) do
nil -> {:error, :not_found}
post -> render_post(project_id, post, language, main_language)
end
end
defp render(project_id, {:category, name, page_number}, language, main_language, metadata) do
posts = load_published_posts_by_category(project_id, name)
posts = maybe_resolve_language(posts, language, main_language, project_id)
render_list(project_id, posts, page_number, metadata, language, main_language, %{
kind: "category",
name: name
})
end
defp render(project_id, {:tag, name, page_number}, language, main_language, metadata) do
posts = load_published_posts_by_tag(project_id, name)
posts = maybe_resolve_language(posts, language, main_language, project_id)
render_list(project_id, posts, page_number, metadata, language, main_language, %{
kind: "tag",
name: name
})
end
defp render(project_id, {:year, year, page_number}, language, main_language, metadata) do
posts = load_published_posts_by_year(project_id, year)
posts = maybe_resolve_language(posts, language, main_language, project_id)
render_list(project_id, posts, page_number, metadata, language, main_language, %{
kind: "date",
year: year
})
end
defp render(project_id, {:month, year, month, page_number}, language, main_language, metadata) do
posts = load_published_posts_by_month(project_id, year, month)
posts = maybe_resolve_language(posts, language, main_language, project_id)
render_list(project_id, posts, page_number, metadata, language, main_language, %{
kind: "date",
year: year,
month: month
})
end
defp render(project_id, {:day, year, month, day, page_number}, language, main_language, metadata) do
posts = load_published_posts_by_day(project_id, year, month, day)
posts = maybe_resolve_language(posts, language, main_language, project_id)
render_list(project_id, posts, page_number, metadata, language, main_language, %{
kind: "date",
year: year,
month: month,
day: day
})
end
## Post rendering
defp render_post(project_id, post, language, main_language) do
{effective_record, body} = resolve_post_for_language(project_id, post, language, main_language)
assigns = %{
id: effective_record.id,
title: effective_record.title,
content: body,
slug: post.slug,
language: Map.get(effective_record, :language, post.language),
excerpt: Map.get(effective_record, :excerpt, post.excerpt),
_post_record: effective_record
}
effective_slug = post.template_slug || TemplateSelection.resolve_post_template_slug(project_id, post.tags, post.categories)
case Rendering.render_post_page(project_id, effective_slug, assigns) do
{:ok, rendered} -> {:ok, rendered}
{:error, _reason} -> {:error, :not_found}
end
end
defp resolve_post_for_language(project_id, post, language, main_language) do
post_lang = String.downcase(to_string(post.language || main_language))
target_lang = String.downcase(to_string(language))
if post_lang == target_lang do
{post, Posts.editor_body(post)}
else
case Repo.get_by(Translation,
translation_for: post.id,
language: language,
project_id: project_id
) do
%Translation{status: status} = translation when status in [:published, :draft] ->
{translation, Posts.editor_body(translation)}
_ ->
{post, Posts.editor_body(post)}
end
end
end
## List rendering
defp render_list(project_id, posts, page_number, metadata, language, main_language, archive_ctx) do
max_per_page = max(metadata.max_posts_per_page || 50, 1)
total_items = length(posts)
total_pages = Paths.page_count(total_items, max_per_page)
if page_number > total_pages and page_number > 1 do
{:error, :not_found}
else
page_posts =
posts
|> Enum.chunk_every(max_per_page)
|> Enum.at(page_number - 1, [])
|> Enum.map(&post_to_list_entry(project_id, &1, language, main_language))
language_prefix = Paths.language_prefix(language, main_language)
route_language = Paths.route_language(main_language, language)
segments = archive_context_to_segments(archive_ctx)
pagination = %{
current_page: page_number,
total_pages: total_pages,
total_items: total_items,
items_per_page: max_per_page,
has_prev_page: page_number > 1,
prev_page_href: Paths.archive_or_root_href(route_language, segments, page_number - 1),
has_next_page: page_number < total_pages,
next_page_href: Paths.archive_or_root_href(route_language, segments, page_number + 1)
}
assigns = %{
language: language,
language_prefix: language_prefix,
page_title: archive_page_title(archive_ctx),
posts: page_posts,
archive_context: archive_ctx,
pagination: pagination
}
try do
case Rendering.render_list_page(project_id, assigns) do
{:ok, rendered} -> {:ok, rendered}
{:error, _reason} -> {:ok, fallback_list_html(page_posts, archive_ctx)}
end
rescue
_ -> {:ok, fallback_list_html(page_posts, archive_ctx)}
end
end
end
defp post_to_list_entry(_project_id, post, language, main_language) do
route_language = Paths.route_language(main_language, language)
%{
id: post.id,
slug: post.slug,
title: post.title,
href: Paths.url_for_output(nil, Paths.post_output_path(post, route_language)),
excerpt: post.excerpt,
content: Posts.editor_body(post),
language: post.language,
author: post.author,
created_at: post.created_at,
updated_at: post.updated_at,
published_at: post.published_at,
tags: post.tags || [],
categories: post.categories || [],
template_slug: post.template_slug,
do_not_translate: Map.get(post, :do_not_translate, false)
}
end
defp archive_context_to_segments(%{kind: "core"}), do: []
defp archive_context_to_segments(%{kind: "category", name: name}), do: ["category", name]
defp archive_context_to_segments(%{kind: "tag", name: name}), do: ["tag", name]
defp archive_context_to_segments(%{kind: "date", year: y, month: m, day: d})
when is_integer(y) and is_integer(m) and is_integer(d) do
[
Integer.to_string(y),
String.pad_leading(Integer.to_string(m), 2, "0"),
String.pad_leading(Integer.to_string(d), 2, "0")
]
end
defp archive_context_to_segments(%{kind: "date", year: y, month: m})
when is_integer(y) and is_integer(m) do
[Integer.to_string(y), String.pad_leading(Integer.to_string(m), 2, "0")]
end
defp archive_context_to_segments(%{kind: "date", year: y}) when is_integer(y),
do: [Integer.to_string(y)]
defp archive_context_to_segments(_), do: []
defp fallback_list_html(posts, archive_ctx) do
title = archive_page_title(archive_ctx) || "Archive"
items =
posts
|> Enum.map(fn post ->
["<li>", to_string(Map.get(post, :title, "")), "</li>"]
end)
|> IO.iodata_to_binary()
IO.iodata_to_binary([
"<html><body><h1>",
title,
"</h1><ul>",
items,
"</ul></body></html>"
])
end
defp archive_page_title(%{kind: "category", name: name}), do: name
defp archive_page_title(%{kind: "tag", name: name}), do: name
defp archive_page_title(%{kind: "date", year: y, month: m, day: d})
when is_integer(y) and is_integer(m) and is_integer(d),
do: "#{y}-#{String.pad_leading(Integer.to_string(m), 2, "0")}-#{String.pad_leading(Integer.to_string(d), 2, "0")}"
defp archive_page_title(%{kind: "date", year: y, month: m})
when is_integer(y) and is_integer(m),
do: "#{y}-#{String.pad_leading(Integer.to_string(m), 2, "0")}"
defp archive_page_title(%{kind: "date", year: y}) when is_integer(y), do: Integer.to_string(y)
defp archive_page_title(_), do: nil
## Data loading
@default_category_settings %{
"article" => %{render_in_lists: true},
"picture" => %{render_in_lists: true},
"aside" => %{render_in_lists: true},
"page" => %{render_in_lists: false}
}
defp load_published_list_posts(project_id, metadata) do
raw_settings = Map.get(metadata, :category_settings, %{}) || %{}
resolved =
Enum.reduce(raw_settings, @default_category_settings, fn {category, settings}, acc ->
flag =
case MapUtils.attr(settings, :render_in_lists, true) do
false -> false
_ -> true
end
Map.put(acc, category, %{render_in_lists: flag})
end)
excluded =
resolved
|> Enum.filter(fn {_cat, settings} -> settings.render_in_lists == false end)
|> Enum.map(&elem(&1, 0))
|> MapSet.new()
project_id
|> load_previewable_posts()
|> Enum.reject(fn post ->
Enum.any?(post.categories || [], &MapSet.member?(excluded, &1))
end)
end
defp load_previewable_posts(project_id) do
Repo.all(
from p in Post,
where: p.project_id == ^project_id and p.status in [:published, :draft],
order_by: [desc: p.created_at, desc: p.published_at, asc: p.slug]
)
end
defp load_published_posts_by_category(project_id, category) do
project_id
|> load_previewable_posts()
|> Enum.filter(fn post -> category in (post.categories || []) end)
end
defp load_published_posts_by_tag(project_id, tag) do
project_id
|> load_previewable_posts()
|> Enum.filter(fn post -> tag in (post.tags || []) end)
end
defp load_published_posts_by_year(project_id, year) do
project_id
|> load_previewable_posts()
|> Enum.filter(fn post ->
{post_year, _, _} = Paths.local_date_parts!(post.created_at)
post_year == year
end)
end
defp load_published_posts_by_month(project_id, year, month) do
project_id
|> load_previewable_posts()
|> Enum.filter(fn post ->
{post_year, post_month, _} = Paths.local_date_parts!(post.created_at)
post_year == year and post_month == month
end)
end
defp load_published_posts_by_day(project_id, year, month, day) do
project_id
|> load_previewable_posts()
|> Enum.filter(fn post ->
{post_year, post_month, post_day} = Paths.local_date_parts!(post.created_at)
post_year == year and post_month == month and post_day == day
end)
end
defp find_post_by_slug_and_date(project_id, slug, year, month, day) do
case Repo.one(
from p in Post,
where:
p.project_id == ^project_id and p.slug == ^slug and
p.status in [:published, :draft]
) do
nil ->
nil
post ->
{post_year, post_month, post_day} = Paths.local_date_parts!(post.created_at)
if post_year == year and post_month == month and post_day == day do
post
else
nil
end
end
end
defp find_page_by_slug(project_id, slug) do
case Repo.one(
from p in Post,
where:
p.project_id == ^project_id and p.slug == ^slug and
p.status in [:published, :draft]
) do
%Post{categories: categories} = post ->
if "page" in (categories || []), do: post, else: nil
nil ->
nil
end
end
## Language resolution
defp maybe_resolve_language(posts, language, main_language, project_id) do
if String.downcase(to_string(language)) == String.downcase(to_string(main_language)) do
posts
else
translations = load_translations_for_language(project_id, Enum.map(posts, & &1.id), language)
Enum.map(posts, fn post ->
case Map.get(translations, post.id) do
nil -> post
translation -> overlay_translation(post, translation)
end
end)
end
end
defp load_translations_for_language(project_id, post_ids, language) do
if Enum.empty?(post_ids) do
%{}
else
Repo.all(
from t in Translation,
where:
t.project_id == ^project_id and
t.translation_for in ^post_ids and
t.language == ^language and
t.status in [:published, :draft]
)
|> Map.new(&{&1.translation_for, &1})
end
end
defp overlay_translation(post, translation) do
%{
post
| id: translation.id,
title: translation.title,
excerpt: translation.excerpt,
content: translation.content,
language: translation.language,
updated_at: translation.updated_at,
published_at: translation.published_at || post.published_at
}
end
## Helpers
defp extract_language_prefix([], _additional_languages), do: {nil, []}
defp extract_language_prefix([first | rest] = segments, additional_languages) do
normalized = String.downcase(first)
if normalized in Enum.map(additional_languages, &String.downcase/1) do
{normalized, rest}
else
{nil, segments}
end
end
defp parse_page(n) do
case Integer.parse(n) do
{page, ""} when page >= 1 -> page
_ -> 1
end
end
end

View File

@@ -148,6 +148,9 @@ defmodule BDS.Projects do
project -> project ->
now = Persistence.now_ms() now = Persistence.now_ms()
previous_active_id =
Repo.one(from p in Project, where: p.is_active == true, select: p.id)
Repo.transaction(fn -> Repo.transaction(fn ->
Repo.update_all( Repo.update_all(
from(p in Project, where: p.is_active == true), from(p in Project, where: p.is_active == true),
@@ -159,8 +162,16 @@ defmodule BDS.Projects do
|> Repo.update!() |> Repo.update!()
end) end)
|> case do |> case do
{:ok, active_project} -> {:ok, active_project} {:ok, active_project} ->
{:error, reason} -> {:error, reason} # Force-save the outgoing project's embedding index (DebouncedPersistence).
if is_binary(previous_active_id) and previous_active_id != active_project.id do
BDS.Embeddings.Index.flush(previous_active_id)
end
{:ok, active_project}
{:error, reason} ->
{:error, reason}
end end
end end
end end
@@ -194,6 +205,8 @@ defmodule BDS.Projects do
end) end)
|> case do |> case do
{:ok, deleted_project} -> {:ok, deleted_project} ->
BDS.Embeddings.Index.forget(deleted_project.id)
Enum.each(cleanup_dirs, fn dir -> Enum.each(cleanup_dirs, fn dir ->
_ = File.rm_rf(dir) _ = File.rm_rf(dir)
end) end)

View File

@@ -3,7 +3,14 @@ defmodule BDS.Rendering.Filters do
use Liquex.Filter use Liquex.Filter
import Ecto.Query
alias BDS.Slug alias BDS.Slug
alias BDS.{Repo}
alias BDS.Media.Media, as: MediaRecord
alias BDS.Posts.{Post, PostMedia}
alias BDS.Tags.Tag
require Logger
@spec i18n(term(), String.t(), Liquex.Context.t()) :: String.t() @spec i18n(term(), String.t(), Liquex.Context.t()) :: String.t()
def i18n(value, language, _context) do def i18n(value, language, _context) do
@@ -28,7 +35,7 @@ defmodule BDS.Rendering.Filters do
) :: String.t() ) :: String.t()
def markdown( def markdown(
value, value,
_post_id, post_id,
_post_data_json_by_id, _post_data_json_by_id,
canonical_post_paths, canonical_post_paths,
canonical_media_paths, canonical_media_paths,
@@ -36,15 +43,15 @@ defmodule BDS.Rendering.Filters do
_language_prefix, _language_prefix,
context context
) do ) do
render_markdown(value, canonical_post_paths, canonical_media_paths, language, context) render_markdown(value, canonical_post_paths, canonical_media_paths, language, context, post_id)
end end
@spec render_markdown(term(), map() | nil, map() | nil, String.t(), Liquex.Context.t()) :: @spec render_markdown(term(), map() | nil, map() | nil, String.t(), Liquex.Context.t(), term()) ::
String.t() String.t()
def render_markdown(value, canonical_post_paths, canonical_media_paths, language, context) do def render_markdown(value, canonical_post_paths, canonical_media_paths, language, context, post_id \\ nil) do
value value
|> to_string() |> to_string()
|> replace_built_in_macros(language, context) |> replace_built_in_macros(language, context, post_id)
|> render_markdown_html() |> render_markdown_html()
|> rewrite_rendered_html_urls(canonical_post_paths || %{}, canonical_media_paths || %{}) |> rewrite_rendered_html_urls(canonical_post_paths || %{}, canonical_media_paths || %{})
end end
@@ -56,7 +63,7 @@ defmodule BDS.Rendering.Filters do
|> Slug.slugify() |> Slug.slugify()
end end
defp replace_built_in_macros(content, language, context) do defp replace_built_in_macros(content, language, context, post_id) do
Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match, Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match,
macro_name, macro_name,
raw_params -> raw_params ->
@@ -88,6 +95,15 @@ defmodule BDS.Rendering.Filters do
context context
) )
"gallery" ->
render_gallery_macro(context, params, post_id)
"photo_archive" ->
render_photo_archive_macro(context, params)
"tag_cloud" ->
render_tag_cloud_macro(context, params)
_other -> _other ->
full_match full_match
end end
@@ -127,14 +143,6 @@ defmodule BDS.Rendering.Filters do
end end
defp render_macro_template(template_path, assigns, context) do defp render_macro_template(template_path, assigns, context) do
case Map.get(assigns, "id") do
"" ->
""
nil ->
""
_id ->
case BDS.Rendering.FileSystem.try_read(context.file_system, template_path) do case BDS.Rendering.FileSystem.try_read(context.file_system, template_path) do
{:ok, template_source} -> {:ok, template_source} ->
render_macro_source(template_path, template_source, assigns, context) render_macro_source(template_path, template_source, assigns, context)
@@ -145,7 +153,6 @@ defmodule BDS.Rendering.Filters do
"" ""
end end
end end
end
defp render_macro_source(template_path, template_source, assigns, context) do defp render_macro_source(template_path, template_source, assigns, context) do
with {:ok, template_ast} <- Liquex.parse(template_source), with {:ok, template_ast} <- Liquex.parse(template_source),
@@ -285,4 +292,307 @@ defmodule BDS.Rendering.Filters do
defp ensure_leading_slash("/" <> _rest = path), do: path defp ensure_leading_slash("/" <> _rest = path), do: path
defp ensure_leading_slash(path), do: "/" <> path defp ensure_leading_slash(path), do: "/" <> path
# ── Built-in macro renderers ───────────────────────────────────────────────
defp render_gallery_macro(context, params, post_id) when is_binary(post_id) do
columns = normalize_columns(Map.get(params, "columns", "3"), 3, 1, 6)
caption = Map.get(params, "caption")
items =
post_id
|> linked_media_images()
|> Enum.map(fn media ->
%{
"media_path" => "/#{media.file_path}",
"title" => media.title || media.original_name,
"alt" => media.alt || media.title || media.original_name,
"group_name" => post_id
}
end)
render_macro_template(
"macros/gallery",
%{
"columns" => columns,
"post_id" => post_id,
"items" => items,
"caption" => caption,
"empty_label" =>
BDS.Gettext.lgettext(
Access.get(context, "language") || "en",
"render",
"No images"
)
},
context
)
end
defp render_gallery_macro(context, params, _post_id) do
render_macro_template(
"macros/gallery",
%{
"columns" => normalize_columns(Map.get(params, "columns", "3"), 3, 1, 6),
"post_id" => "",
"items" => [],
"caption" => Map.get(params, "caption"),
"empty_label" =>
BDS.Gettext.lgettext(
Access.get(context, "language") || "en",
"render",
"No images"
)
},
context
)
end
defp render_photo_archive_macro(context, params) do
language = Access.get(context, "language") || "en"
project_id = project_id_from_context(context)
months =
if project_id do
media_month_archive(project_id, Map.get(params, "year"), Map.get(params, "month"))
else
[]
end
render_macro_template(
"macros/photo-archive",
%{
"root_classes" => "macro-photo-archive",
"data_attrs" => [],
"months" => months,
"empty_label" =>
BDS.Gettext.lgettext(language, "render", "No photos found")
},
context
)
end
defp render_tag_cloud_macro(context, params) do
language = Access.get(context, "language") || "en"
project_id = project_id_from_context(context)
{words_json, width, height} =
if project_id do
build_tag_cloud_data(project_id)
else
{nil, 800, 400}
end
render_macro_template(
"macros/tag-cloud",
%{
"orientation" => Map.get(params, "orientation", "horizontal"),
"words_json" => words_json,
"width" => Map.get(params, "width", width),
"height" => Map.get(params, "height", height),
"aria_label" => "Tag cloud",
"empty_label" =>
BDS.Gettext.lgettext(language, "render", "No tags")
},
context
)
end
# ── Data queries for macros ────────────────────────────────────────────────
defp linked_media_images(post_id) do
Repo.all(
from pm in PostMedia,
join: m in MediaRecord,
on: pm.media_id == m.id,
where: pm.post_id == ^post_id,
where: like(m.mime_type, "image/%"),
order_by: [asc: pm.sort_order, asc: pm.media_id],
select: m
)
end
defp media_month_archive(project_id, year, month) do
query =
from m in MediaRecord,
where: m.project_id == ^project_id,
where: like(m.mime_type, "image/%"),
order_by: [desc: m.created_at],
select: m
query =
if year do
year_int = parse_integer(year)
if month do
month_int = parse_integer(month)
start_ts = month_start_ms(year_int, month_int)
end_ts = month_end_ms(year_int, month_int)
from m in query,
where: m.created_at >= ^start_ts and m.created_at <= ^end_ts
else
start_ts = month_start_ms(year_int, 1)
end_ts = month_end_ms(year_int, 12)
from m in query,
where: m.created_at >= ^start_ts and m.created_at <= ^end_ts
end
else
from m in query, limit: 200
end
media_records =
query
|> Repo.all()
|> group_by_media_month()
if year == nil do
Enum.take(media_records, 10)
else
media_records
end
end
defp group_by_media_month(media_records) do
month_names = %{
1 => "January", 2 => "February", 3 => "March", 4 => "April",
5 => "May", 6 => "June", 7 => "July", 8 => "August",
9 => "September", 10 => "October", 11 => "November", 12 => "December"
}
media_records
|> Enum.group_by(fn m ->
date = DateTime.from_unix!(div(m.created_at, 1000))
{date.year, date.month}
end)
|> Enum.sort_by(fn {{y, m}, _} -> {y, m} end, :desc)
|> Enum.map(fn {{year, month}, items} ->
%{
"label" => "#{Map.get(month_names, month)} #{year}",
"items" =>
Enum.map(items, fn m ->
%{
"media_path" => "/#{m.file_path}",
"title" => m.title || m.original_name,
"alt" => m.alt || m.title || m.original_name,
"group_name" => "#{year}-#{month}"
}
end)
}
end)
end
defp build_tag_cloud_data(project_id) do
tag_colors =
Repo.all(
from tag in Tag,
where: tag.project_id == ^project_id,
where: not is_nil(tag.color) and tag.color != "",
select: {tag.name, tag.color}
)
|> Map.new()
%{rows: rows} =
Ecto.Adapters.SQL.query!(
Repo,
"""
SELECT trim(je.value) AS tag, COUNT(*) AS cnt
FROM posts, json_each(posts.tags) je
WHERE posts.project_id = ?1
AND trim(je.value) != ''
GROUP BY tag
ORDER BY cnt DESC, lower(tag) ASC
""",
[project_id]
)
tag_entries =
Enum.map(rows, fn [tag, count] ->
%{tag: tag, count: count, color: Map.get(tag_colors, tag)}
end)
if tag_entries == [] do
{nil, 0, 0}
else
max_count = Enum.map(tag_entries, & &1.count) |> Enum.max()
min_count = Enum.map(tag_entries, & &1.count) |> Enum.min()
range = max(max_count - min_count, 1)
words =
Enum.map(tag_entries, fn %{tag: tag, count: count, color: color} ->
size = 12.0 + (count - min_count) / range * 28.0
%{"text" => tag, "size" => size, "color" => color || "var(--accent-color)"}
end)
{Jason.encode!(words), 800, 400}
end
end
# ── Helpers ────────────────────────────────────────────────────────────────
defp project_id_from_context(context) do
post = Access.get(context, "post") || %{}
post["project_id"] || Access.get(post, :project_id) || project_id_from_post(context)
end
defp project_id_from_post(context) do
post_id =
Access.get(Access.get(context, "post") || %{}, "id") ||
Access.get(Access.get(context, "post") || %{}, :id)
if is_binary(post_id) do
case Repo.one(from p in Post, where: p.id == ^post_id, select: p.project_id) do
nil -> nil
project_id -> project_id
end
end
end
defp normalize_columns(value, default, min, max) when is_binary(value) do
case Integer.parse(value) do
{n, ""} -> n |> max(min) |> min(max)
_ -> default
end
end
defp normalize_columns(value, _default, min, max) when is_integer(value),
do: value |> max(min) |> min(max)
defp normalize_columns(_value, default, _min, _max), do: default
defp parse_integer(value) when is_binary(value) do
case Integer.parse(value) do
{n, ""} -> n
_ -> nil
end
end
defp parse_integer(value) when is_integer(value), do: value
defp parse_integer(_value), do: nil
defp month_start_ms(year, month) do
case DateTime.from_iso8601("#{year}-#{pad(month)}-01T00:00:00Z") do
{:ok, dt, _} -> DateTime.to_unix(dt, :millisecond)
_ -> 0
end
end
defp month_end_ms(year, month) do
last_day =
if month == 12 do
31
else
case DateTime.from_iso8601("#{year}-#{pad(month + 1)}-01T00:00:00Z") do
{:ok, dt, _} ->
dt |> DateTime.add(-1, :second) |> DateTime.to_date() |> Map.get(:day)
_ ->
31
end
end
case DateTime.from_iso8601("#{year}-#{pad(month)}-#{pad(last_day)}T23:59:59.999Z") do
{:ok, dt, _} -> DateTime.to_unix(dt, :millisecond)
_ -> 0
end
end
defp pad(n) when is_integer(n), do: n |> Integer.to_string() |> String.pad_leading(2, "0")
end end

View File

@@ -27,6 +27,7 @@ defmodule BDS.Rendering.Labels do
language_switcher_label: dgettext("render", "Language"), language_switcher_label: dgettext("render", "Language"),
site_search_label: dgettext("render", "Site search"), site_search_label: dgettext("render", "Site search"),
search_placeholder: dgettext("render", "Search..."), search_placeholder: dgettext("render", "Search..."),
search_no_results: dgettext("render", "No results found"),
not_found_message: dgettext("render", "The requested preview page could not be found."), not_found_message: dgettext("render", "The requested preview page could not be found."),
not_found_back_label: dgettext("render", "Back to preview home"), not_found_back_label: dgettext("render", "Back to preview home"),
youtube_video: dgettext("render", "YouTube video"), youtube_video: dgettext("render", "YouTube video"),

View File

@@ -10,7 +10,9 @@ defmodule BDS.Rendering.PostRendering do
alias BDS.Rendering.TemplateSelection alias BDS.Rendering.TemplateSelection
alias BDS.MapUtils alias BDS.MapUtils
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.PostMedia
alias BDS.Posts.Translation alias BDS.Posts.Translation
alias BDS.Media.Media, as: MediaRecord
alias BDS.Repo alias BDS.Repo
@spec post_assigns(String.t(), map()) :: map() @spec post_assigns(String.t(), map()) :: map()
@@ -39,7 +41,8 @@ defmodule BDS.Rendering.PostRendering do
canonical_post_paths, canonical_post_paths,
canonical_media_paths, canonical_media_paths,
language, language,
template_context template_context,
post_id
) )
incoming_links = incoming_links =
@@ -208,6 +211,7 @@ defmodule BDS.Rendering.PostRendering do
title: MapUtils.attr(assigns, :title), title: MapUtils.attr(assigns, :title),
content: MapUtils.attr(assigns, :content), content: MapUtils.attr(assigns, :content),
raw_content: MapUtils.attr(assigns, :raw_content), raw_content: MapUtils.attr(assigns, :raw_content),
project_id: MapUtils.attr(assigns, :project_id) || Map.get(post_record || %{}, :project_id),
excerpt: excerpt:
Map.get( Map.get(
assigns, assigns,
@@ -234,7 +238,7 @@ defmodule BDS.Rendering.PostRendering do
MapUtils.attr(assigns, :template_slug) MapUtils.attr(assigns, :template_slug)
), ),
do_not_translate: Map.get(post_record || %{}, :do_not_translate, false), do_not_translate: Map.get(post_record || %{}, :do_not_translate, false),
linked_media: [], linked_media: linked_media_images(assigns),
outgoing_links: outgoing_links, outgoing_links: outgoing_links,
incoming_links: incoming_links incoming_links: incoming_links
} }
@@ -245,21 +249,42 @@ defmodule BDS.Rendering.PostRendering do
map(), map(),
map(), map(),
String.t(), String.t(),
Liquex.Context.t() Liquex.Context.t(),
term()
) :: String.t() ) :: String.t()
def render_post_content( def render_post_content(
content, content,
canonical_post_paths, canonical_post_paths,
canonical_media_paths, canonical_media_paths,
language, language,
template_context template_context,
post_id \\ nil
) do ) do
Filters.render_markdown( Filters.render_markdown(
content, content,
canonical_post_paths, canonical_post_paths,
canonical_media_paths, canonical_media_paths,
language, language,
template_context template_context,
post_id
) )
end end
defp linked_media_images(assigns) do
post_id = MapUtils.attr(assigns, :id)
if is_binary(post_id) do
Repo.all(
from pm in PostMedia,
join: m in MediaRecord,
on: pm.media_id == m.id,
where: pm.post_id == ^post_id,
where: like(m.mime_type, "image/%"),
order_by: [asc: pm.sort_order, asc: pm.media_id],
select: m
)
else
[]
end
end
end end

View File

@@ -4,13 +4,52 @@ defmodule BDS.Rendering.TemplateSelection do
import Ecto.Query import Ecto.Query
alias BDS.Frontmatter alias BDS.Frontmatter
alias BDS.Metadata
alias BDS.Projects alias BDS.Projects
alias BDS.Rendering.FileSystem alias BDS.Rendering.FileSystem
alias BDS.Rendering.Filters alias BDS.Rendering.Filters
alias BDS.Repo alias BDS.Repo
alias BDS.StarterTemplates alias BDS.StarterTemplates
alias BDS.Tags.Tag
alias BDS.Templates.Template alias BDS.Templates.Template
@spec resolve_post_template_slug(String.t(), [String.t()], [String.t()]) ::
String.t() | nil
def resolve_post_template_slug(project_id, tag_names, category_names) do
resolve_from_tags(project_id, tag_names) ||
resolve_from_categories(project_id, category_names)
end
defp resolve_from_tags(_project_id, []), do: nil
defp resolve_from_tags(project_id, tag_names) do
Repo.all(
from tag in Tag,
where:
tag.project_id == ^project_id and
tag.name in ^tag_names and
not is_nil(tag.post_template_slug) and
tag.post_template_slug != "",
select: tag.post_template_slug,
limit: 1
)
|> List.first()
end
defp resolve_from_categories(_project_id, []), do: nil
defp resolve_from_categories(project_id, category_names) do
{:ok, state} = Metadata.get_project_metadata(project_id)
settings = state.category_settings || %{}
Enum.find_value(category_names, fn cat_name ->
case Map.get(settings, cat_name) do
%{"post_template_slug" => slug} when is_binary(slug) and slug != "" -> slug
_ -> nil
end
end)
end
@spec load_template_source(String.t(), atom(), String.t() | nil) :: @spec load_template_source(String.t(), atom(), String.t() | nil) ::
{:ok, String.t()} | {:error, term()} {:ok, String.t()} | {:error, term()}
def load_template_source(project_id, kind, slug) do def load_template_source(project_id, kind, slug) do

View File

@@ -57,10 +57,13 @@ defmodule BDS.Scripts do
{:error, :not_found} {:error, :not_found}
script -> script ->
content = script.content || ""
case validate_script(content) do
:ok ->
file_path = script_file_path(script.slug) file_path = script_file_path(script.slug)
full_path = full_file_path(script.project_id, file_path) full_path = full_file_path(script.project_id, file_path)
updated_at = Persistence.now_ms() updated_at = Persistence.now_ms()
content = script.content || ""
:ok = :ok =
Persistence.atomic_write( Persistence.atomic_write(
@@ -79,6 +82,10 @@ defmodule BDS.Scripts do
updated_at: updated_at updated_at: updated_at
}) })
|> Repo.update() |> Repo.update()
{:error, reason} ->
{:error, {:invalid_script, reason}}
end
end end
end end
@@ -254,6 +261,13 @@ defmodule BDS.Scripts do
not Repo.exists?(scoped_query) not Repo.exists?(scoped_query)
end end
defp validate_script(source) do
case BDS.Scripting.validate(source) do
:ok -> :ok
{:error, reason} -> {:error, reason}
end
end
defp script_file_path(slug), do: Path.join(["scripts", "#{slug}.lua"]) defp script_file_path(slug), do: Path.join(["scripts", "#{slug}.lua"])
defp full_file_path(project_id, relative_path) do defp full_file_path(project_id, relative_path) do

View File

@@ -28,23 +28,38 @@ defmodule BDS.Templates do
now = Persistence.now_ms() now = Persistence.now_ms()
project_id = attr(attrs, :project_id) project_id = attr(attrs, :project_id)
title = attr(attrs, :title) || "" title = attr(attrs, :title) || ""
slug = unique_slug(project_id, Slug.slugify(title), "template")
file_path = template_file_path(slug)
changeset =
%Template{} %Template{}
|> Template.changeset(%{ |> Template.changeset(%{
id: Ecto.UUID.generate(), id: Ecto.UUID.generate(),
project_id: project_id, project_id: project_id,
slug: unique_slug(project_id, Slug.slugify(title), "template"), slug: slug,
title: title, title: title,
kind: attr(attrs, :kind), kind: attr(attrs, :kind),
enabled: true, enabled: true,
version: 1, version: 1,
file_path: "", file_path: file_path,
status: :draft, status: :draft,
content: attr(attrs, :content), content: attr(attrs, :content),
created_at: now, created_at: now,
updated_at: now updated_at: now
}) })
|> Repo.insert()
with {:ok, template} <- Repo.insert(changeset) do
full_path = full_file_path(template.project_id, file_path)
File.mkdir_p!(Path.dirname(full_path))
:ok =
Persistence.atomic_write(
full_path,
serialize_template_file(template, template.content || "")
)
{:ok, template}
end
end end
@spec get_template(String.t()) :: Template.t() | nil @spec get_template(String.t()) :: Template.t() | nil
@@ -62,10 +77,13 @@ defmodule BDS.Templates do
{:error, :not_found} {:error, :not_found}
template -> template ->
content = template.content || ""
case validate_liquid(content) do
:ok ->
file_path = template_file_path(template.slug) file_path = template_file_path(template.slug)
full_path = full_file_path(template.project_id, file_path) full_path = full_file_path(template.project_id, file_path)
updated_at = Persistence.now_ms() updated_at = Persistence.now_ms()
content = template.content || ""
:ok = :ok =
Persistence.atomic_write( Persistence.atomic_write(
@@ -84,6 +102,10 @@ defmodule BDS.Templates do
updated_at: updated_at updated_at: updated_at
}) })
|> Repo.update() |> Repo.update()
{:error, reason} ->
{:error, {:invalid_liquid, reason}}
end
end end
end end
@@ -327,6 +349,13 @@ defmodule BDS.Templates do
not Repo.exists?(scoped_query) not Repo.exists?(scoped_query)
end end
defp validate_liquid(source) do
case Liquex.parse(source) do
{:ok, _ast} -> :ok
{:error, reason, line} -> {:error, "#{reason} at line #{line}"}
end
end
defp template_file_path(slug), do: Path.join(["templates", "#{slug}.liquid"]) defp template_file_path(slug), do: Path.join(["templates", "#{slug}.liquid"])
defp full_file_path(project_id, relative_path) do defp full_file_path(project_id, relative_path) do
@@ -334,7 +363,6 @@ defmodule BDS.Templates do
Path.join(Projects.project_data_dir(project), relative_path) Path.join(Projects.project_data_dir(project), relative_path)
end end
defp next_template_file_path(%Template{file_path: ""}, _next_slug), do: ""
defp next_template_file_path(%Template{}, next_slug), do: template_file_path(next_slug) defp next_template_file_path(%Template{}, next_slug), do: template_file_path(next_slug)
defp serialize_template_file(template, content) do defp serialize_template_file(template, content) do

View File

@@ -35,7 +35,7 @@ defmodule BDS.UI.Sidebar do
"import", "import",
list_import_definitions(project_id) list_import_definitions(project_id)
), ),
"git" => git_view(), "git" => git_view(project_id),
"settings" => settings_nav_view() "settings" => settings_nav_view()
} }
end end
@@ -94,7 +94,7 @@ defmodule BDS.UI.Sidebar do
) )
"git" -> "git" ->
git_view() git_view(project_id)
"settings" -> "settings" ->
settings_nav_view() settings_nav_view()
@@ -139,13 +139,17 @@ defmodule BDS.UI.Sidebar do
"import", "import",
[] []
), ),
"git" => git_view(), "git" => git_view(nil),
"settings" => settings_nav_view() "settings" => settings_nav_view()
} }
end end
defp empty_view("posts"), do: build_posts_view([], %{}, false, empty_filter_params(), %{}, [], [], []) defp empty_view("posts"),
defp empty_view("pages"), do: build_posts_view([], %{}, true, empty_filter_params(), %{}, [], [], []) do: build_posts_view([], %{}, false, empty_filter_params(), %{}, [], [], [])
defp empty_view("pages"),
do: build_posts_view([], %{}, true, empty_filter_params(), %{}, [], [], [])
defp empty_view("media"), do: build_media_view([], empty_filter_params(), %{}, [], [], 0) defp empty_view("media"), do: build_media_view([], empty_filter_params(), %{}, [], [], 0)
defp empty_view("scripts"), defp empty_view("scripts"),
@@ -186,7 +190,7 @@ defmodule BDS.UI.Sidebar do
[] []
) )
defp empty_view("git"), do: git_view() defp empty_view("git"), do: git_view(nil)
defp empty_view("settings"), do: settings_nav_view() defp empty_view("settings"), do: settings_nav_view()
defp empty_view(_other), defp empty_view(_other),
@@ -563,7 +567,14 @@ defmodule BDS.UI.Sidebar do
build_media_view(limited_media, filters, tag_colors, year_months, avail_tags, total_count) build_media_view(limited_media, filters, tag_colors, year_months, avail_tags, total_count)
end end
defp build_media_view(limited_media, filters, tag_colors, year_month_counts, available_tags, total_count) do defp build_media_view(
limited_media,
filters,
tag_colors,
year_month_counts,
available_tags,
total_count
) do
loaded_count = length(limited_media) loaded_count = length(limited_media)
%{ %{
@@ -779,24 +790,115 @@ defmodule BDS.UI.Sidebar do
} }
end end
defp git_view do @git_history_page_size 20
%{
defp git_view(project_id) do
base = %{
title: dgettext("ui", "Git"), title: dgettext("ui", "Git"),
subtitle: dgettext("ui", "Working tree and history"), subtitle: dgettext("ui", "Working tree and history"),
layout: "entity_list", layout: "git",
empty_message: dgettext("ui", "No items"), empty_message: dgettext("ui", "No items")
items: [
%{
id: "git-working-tree",
title: dgettext("ui", "Working tree"),
meta: dgettext("ui", "Working tree and history"),
route: "git_diff",
updated_at: nil
} }
]
if git_repo?(project_id) do
Map.merge(base, active_git_view(project_id))
else
Map.merge(base, %{git_state: "not_a_repo", remote_url: nil})
end
end
defp git_repo?(nil), do: false
defp git_repo?(project_id) when is_binary(project_id) do
case BDS.Projects.get_project(project_id) do
nil -> false
project -> File.dir?(Path.join(BDS.Projects.project_data_dir(project), ".git"))
end
end
defp active_git_view(project_id) do
repo =
case BDS.Git.repository(project_id) do
{:ok, repo} -> repo
_other -> %{current_branch: nil, remote_url: nil}
end
branch = repo[:current_branch]
remote =
case BDS.Git.remote_state(project_id) do
{:ok, state} -> state
_other -> %{upstream_branch: nil, ahead: 0, behind: 0}
end
status_files =
case BDS.Git.status(project_id) do
{:ok, %{files: files}} -> Enum.map(files, &git_status_file/1)
_other -> []
end
commits =
if is_binary(branch) do
case BDS.Git.history(project_id, branch) do
{:ok, %{commits: commits}} -> commits
_other -> []
end
else
[]
end
%{
git_state: "active",
branch: branch,
upstream: remote[:upstream_branch],
ahead: remote[:ahead] || 0,
behind: remote[:behind] || 0,
status_files: status_files,
history_entries:
commits |> Enum.take(@git_history_page_size) |> Enum.map(&git_history_entry/1),
has_more_history: length(commits) > @git_history_page_size,
remote_url: repo[:remote_url]
} }
end end
defp git_status_file(%{status: status} = file) do
%{
path: Map.get(file, :path, ""),
status: to_string(status),
code: git_status_code(status),
label: git_status_label(status)
}
end
defp git_status_code(:added), do: "A"
defp git_status_code(:deleted), do: "D"
defp git_status_code(:modified), do: "M"
defp git_status_code(:renamed), do: "R"
defp git_status_code(:untracked), do: "U"
defp git_status_code(_other), do: "M"
defp git_status_label(:added), do: dgettext("ui", "added")
defp git_status_label(:deleted), do: dgettext("ui", "deleted")
defp git_status_label(:modified), do: dgettext("ui", "modified")
defp git_status_label(:renamed), do: dgettext("ui", "renamed")
defp git_status_label(:untracked), do: dgettext("ui", "untracked")
defp git_status_label(_other), do: dgettext("ui", "modified")
defp git_history_entry(commit) do
%{
short_hash: commit |> Map.get(:hash, "") |> String.slice(0, 7),
subject: Map.get(commit, :subject),
author: Map.get(commit, :author),
date: Map.get(commit, :date),
sync_status: git_sync_status(get_in(commit, [:sync_status, :kind]))
}
end
defp git_sync_status(:both), do: "synced"
defp git_sync_status(:local_only), do: "local_only"
defp git_sync_status(:remote_only), do: "remote_only"
defp git_sync_status(_other), do: "synced"
defp entity_list_view(title, subtitle, route, items) do defp entity_list_view(title, subtitle, route, items) do
%{ %{
title: title, title: title,

View File

@@ -33,7 +33,11 @@ defmodule BDS.MixProject do
{:plug, "~> 1.18"}, {:plug, "~> 1.18"},
{:bandit, "~> 1.5"}, {:bandit, "~> 1.5"},
{:desktop, "~> 1.5"}, {:desktop, "~> 1.5"},
{:image, "~> 0.65"}, {:image, "~> 0.67"},
{:nx, "~> 0.10"},
{:exla, "~> 0.10"},
{:bumblebee, "~> 0.6.3"},
{:hnswlib, "~> 0.1.7"},
{:stemex, "~> 0.2.1"}, {:stemex, "~> 0.2.1"},
{:gettext, "~> 0.24"}, {:gettext, "~> 0.24"},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
@@ -60,7 +64,7 @@ defmodule BDS.MixProject do
env = Mix.env() env = Mix.env()
[ [
plt_add_apps: [:mix, :inets, :ssl], plt_add_apps: [:mix, :inets, :ssl, :nx, :exla, :bumblebee, :hnswlib],
paths: ["_build/#{env}/lib/bds/ebin"] paths: ["_build/#{env}/lib/bds/ebin"]
] ]
end end

View File

@@ -1,12 +1,16 @@
%{ %{
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, "axon": {:hex, :axon, "0.7.0", "2e2c6d93b4afcfa812566b8922204fa022b60081e86ebd411df4db7ea30f5457", [:mix], [{:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}, {:kino_vega_lite, "~> 0.1.7", [hex: :kino_vega_lite, repo: "hexpm", optional: true]}, {:nx, "~> 0.9", [hex: :nx, repo: "hexpm", optional: false]}, {:polaris, "~> 0.1", [hex: :polaris, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1", [hex: :table_rex, repo: "hexpm", optional: true]}], "hexpm", "ee9857a143c9486597ceff434e6ca833dc1241be6158b01025b8217757ed1036"},
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
"bumblebee": {:hex, :bumblebee, "0.6.3", "c0028643c92de93258a9804da1d4d48797eaf7911b702464b3b3dd2cc7f938f1", [:mix], [{:axon, "~> 0.7.0", [hex: :axon, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.9.0 or ~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:nx_image, "~> 0.1.0", [hex: :nx_image, repo: "hexpm", optional: false]}, {:nx_signal, "~> 0.2.0", [hex: :nx_signal, repo: "hexpm", optional: false]}, {:progress_bar, "~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: false]}, {:safetensors, "~> 0.1.3", [hex: :safetensors, repo: "hexpm", optional: false]}, {:tokenizers, "~> 0.4", [hex: :tokenizers, repo: "hexpm", optional: false]}, {:unpickler, "~> 0.1.0", [hex: :unpickler, repo: "hexpm", optional: false]}, {:unzip, "~> 0.12.0", [hex: :unzip, repo: "hexpm", optional: false]}], "hexpm", "c619197787561f8e5fb2ffba269c341654accaec9d591999b7fddd55761dd079"},
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"color": {:hex, :color, "0.12.0", "f59f9bb6452a460760d44116ec0c1cf86f9d7707c8756c01f83c6d8fe042ae67", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "1e17768919dad0bd44f48d0daf294d24bdd5a615bbfe0b4e01a51312203bd294"}, "color": {:hex, :color, "0.13.0", "068110e5397ac5d3c9f97658282e0f4ab9a32468be6d7a2a91a8804e67b228d7", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "de127946869931d418bac2d82dc29feae1a8f5f729f135922fbccf0059a58ab2"},
"complex": {:hex, :complex, "0.7.0", "695632ef9487517aa5d57edd1697801079d622414cb2e1a7cf538b1f9a50f205", [:mix], [], "hexpm", "0ee39c0803129f546e7f3f640da8f021c9e659402bf59da6f7f2c4848f068f8d"},
"date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"}, "date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"dbus": {:hex, :dbus, "0.8.0", "7c800681f35d909c199265e55a8ee4aea9ebe4acccce77a0740f89f29cc57648", [:make], [], "hexpm", "a9784f2d9717ffa1f74169144a226c39633ac0d9c7fe8cb3594aeb89c827cca5"}, "dbus": {:hex, :dbus, "0.8.0", "7c800681f35d909c199265e55a8ee4aea9ebe4acccce77a0740f89f29cc57648", [:make], [], "hexpm", "a9784f2d9717ffa1f74169144a226c39633ac0d9c7fe8cb3594aeb89c827cca5"},
"debouncer": {:hex, :debouncer, "0.1.13", "af5906b231c196943ac8386b5b5f45a2f36d54a8bcd7e1b29eef2671de33d287", [:mix], [], "hexpm", "a14f57420c7d4a287f8f08e715fc8759b5d28dcd1032f9585d57c45d22123382"}, "debouncer": {:hex, :debouncer, "0.1.13", "af5906b231c196943ac8386b5b5f45a2f36d54a8bcd7e1b29eef2671de33d287", [:mix], [], "hexpm", "a14f57420c7d4a287f8f08e715fc8759b5d28dcd1032f9585d57c45d22123382"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
"desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"}, "desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
@@ -19,15 +23,17 @@
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"}, "ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"}, "ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"}, "ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
"exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"}, "exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"hnswlib": {:hex, :hnswlib, "0.1.7", "784afdbfbc9af53e64d4b6da3f685c07039e472636a98fa954ffae5292ad6cc4", [:make, :mix], [{:cc_precompiler, "~> 0.1.0", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "fb43bb675facc8bb1ef0f4f8fec92479fc23317ed0f35c7160b2f95aff3e4742"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"},
"image": {:hex, :image, "0.65.0", "44908233a1a0dcdbb6ae873ec09fd9ae533d1840d300d8b0b1b186d586b935e6", [:mix], [{:color, "~> 0.4", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "0.11.0", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.11.0", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "d2060e08d0f42564f49de1ea97a82a5d237f9ac91edb141dece51f1238dd8b4a"}, "image": {:hex, :image, "0.67.0", "886325f45bd39f3d705d32f223680163f3eaba142526d34f7f871c2232577e64", [:mix], [{:color, "~> 0.13", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.10", [hex: :exla, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.10", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "401c3e13137af8932eee377ad8bc8a8ae1a8343894266543e8bedd36b414c999"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"}, "kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"},
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"}, "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"}, "liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"},
@@ -35,23 +41,35 @@
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"}, "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nx": {:hex, :nx, "0.10.0", "128e4a094cb790f663e20e1334b127c1f2a4df54edfb8b13c22757ec33133b4f", [:mix], [{:complex, "~> 0.6", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3db8892c124aeee091df0e6fbf8e5bf1b81f502eb0d4f5ba63e6378ebcae7da4"},
"nx_image": {:hex, :nx_image, "0.1.2", "0c6e3453c1dc30fc80c723a54861204304cebc8a89ed3b806b972c73ee5d119d", [:mix], [{:nx, "~> 0.4", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "9161863c42405ddccb6dbbbeae078ad23e30201509cc804b3b3a7c9e98764b81"},
"nx_signal": {:hex, :nx_signal, "0.2.0", "e1ca0318877b17c81ce8906329f5125f1e2361e4c4235a5baac8a95ee88ea98e", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "7247e5e18a177a59c4cb5355952900c62fdeadeb2bad02a9a34237b68744e2bb"},
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"}, "oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"polaris": {:hex, :polaris, "0.1.0", "dca61b18e3e801ecdae6ac9f0eca5f19792b44a5cb4b8d63db50fc40fc038d22", [:mix], [{:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "13ef2b166650e533cb24b10e2f3b8ab4f2f449ba4d63156e8c569527f206e2c2"},
"progress_bar": {:hex, :progress_bar, "3.0.0", "f54ff038c2ac540cfbb4c2bfe97c75e7116ead044f3c2b10c9f212452194b5cd", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "6981c2b25ab24aecc91a2dc46623658e1399c21a2ae24db986b90d678530f2b7"},
"rustler": {:hex, :rustler, "0.32.1", "f4cf5a39f9e85d182c0a3f75fa15b5d0add6542ab0bf9ceac6b4023109ebd3fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "b96be75526784f86f6587f051bc8d6f4eaff23d6e0f88dbcfe4d5871f52946f7"}, "rustler": {:hex, :rustler, "0.32.1", "f4cf5a39f9e85d182c0a3f75fa15b5d0add6542ab0bf9ceac6b4023109ebd3fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "b96be75526784f86f6587f051bc8d6f4eaff23d6e0f88dbcfe4d5871f52946f7"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"safetensors": {:hex, :safetensors, "0.1.3", "7ff3c22391e213289c713898481d492c9c28a49ab1d0705b72630fb8360426b2", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "fe50b53ea59fde4e723dd1a2e31cfdc6013e69343afac84c6be86d6d7c562c14"},
"saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"}, "saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"},
"stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"}, "stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"tokenizers": {:hex, :tokenizers, "0.5.1", "b0975d92b4ee5b18e8f47b5d65b9d5f1e583d9130189b1a2620401af4e7d4b35", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5f08d97cc7f2ed3d71d370d68120da6d3de010948ccf676c9c0eb591ba4bacc9"},
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
"unpickler": {:hex, :unpickler, "0.1.0", "c2262c0819e6985b761e7107546cef96a485f401816be5304a65fdd200d5bd6a", [:mix], [], "hexpm", "e2b3f61e62406187ac52afead8a63bfb4e49394028993f3c4c42712743cab79e"},
"unzip": {:hex, :unzip, "0.12.0", "beed92238724732418b41eba77dcb7f51e235b707406c05b1732a3052d1c0f36", [:mix], [], "hexpm", "95655b72db368e5a84951f0bed586ac053b55ee3815fd96062fce10ce4fc998d"},
"vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"}, "vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"xla": {:hex, :xla, "0.9.1", "cca0040ff94902764007a118871bfc667f1a0085d4a5074533a47d6b58bec61e", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "eb5e443ae5391b1953f253e051f2307bea183b59acee138053a9300779930daf"},
} }

File diff suppressed because one or more lines are too long

View File

@@ -15,7 +15,7 @@
</svg> </svg>
</button> </button>
<div class="blog-search-panel" data-blog-search-panel hidden> <div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}"></div> <div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}" data-search-no-results="{{ labels.search_no_results }}"></div>
</div> </div>
</div> </div>
</nav> </nav>
@@ -35,7 +35,7 @@
</svg> </svg>
</button> </button>
<div class="blog-search-panel" data-blog-search-panel hidden> <div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}"></div> <div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}" data-search-no-results="{{ labels.search_no_results }}"></div>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@@ -1,156 +1,161 @@
#: lib/bds/rendering/labels.ex:17 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:241 #: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:316 #: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "Archiv" msgstr "Archiv"
#: lib/bds/rendering/labels.ex:52 #: lib/bds/rendering/labels.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "April" msgid "April"
msgstr "Apr." msgstr "Apr."
#: lib/bds/rendering/labels.ex:24 #: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Archive calendar" msgid "Archive calendar"
msgstr "Archiv" msgstr "Archiv"
#: lib/bds/rendering/labels.ex:68 #: lib/bds/rendering/labels.ex:71
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "August" msgid "August"
msgstr "Aug." msgstr "Aug."
#: lib/bds/rendering/labels.ex:15 #: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Backlinks" msgid "Backlinks"
msgstr "Rückverweise" msgstr "Rückverweise"
#: lib/bds/rendering/labels.ex:23 #: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded." msgid "Calendar data could not be loaded."
msgstr "Kalenderdaten konnten nicht geladen werden." msgstr "Kalenderdaten konnten nicht geladen werden."
#: lib/bds/rendering/labels.ex:25 #: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Close calendar" msgid "Close calendar"
msgstr "Kalender schließen" msgstr "Kalender schließen"
#: lib/bds/rendering/labels.ex:84 #: lib/bds/rendering/labels.ex:87
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "December" msgid "December"
msgstr "Dezember" msgstr "Dezember"
#: lib/bds/rendering/labels.ex:44 #: lib/bds/rendering/labels.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "February" msgid "February"
msgstr "Februar" msgstr "Februar"
#: lib/bds/rendering/labels.ex:40 #: lib/bds/rendering/labels.ex:43
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "January" msgid "January"
msgstr "Januar" msgstr "Januar"
#: lib/bds/rendering/labels.ex:64 #: lib/bds/rendering/labels.ex:67
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "July" msgid "July"
msgstr "Juli" msgstr "Juli"
#: lib/bds/rendering/labels.ex:60 #: lib/bds/rendering/labels.ex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "June" msgid "June"
msgstr "Juni" msgstr "Juni"
#: lib/bds/rendering/labels.ex:26 #: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Language" msgid "Language"
msgstr "Sprache" msgstr "Sprache"
#: lib/bds/rendering/labels.ex:16 #: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked from" msgid "Linked from"
msgstr "Verlinkt von" msgstr "Verlinkt von"
#: lib/bds/rendering/labels.ex:22 #: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Loading calendar…" msgid "Loading calendar…"
msgstr "Kalender wird geladen …" msgstr "Kalender wird geladen …"
#: lib/bds/rendering/labels.ex:48 #: lib/bds/rendering/labels.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "March" msgid "March"
msgstr "März" msgstr "März"
#: lib/bds/rendering/labels.ex:56 #: lib/bds/rendering/labels.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "May" msgid "May"
msgstr "Mai" msgstr "Mai"
#: lib/bds/rendering/labels.ex:80 #: lib/bds/rendering/labels.ex:83
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "November" msgid "November"
msgstr "Nov." msgstr "Nov."
#: lib/bds/rendering/labels.ex:76 #: lib/bds/rendering/labels.ex:79
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "October" msgid "October"
msgstr "Oktober" msgstr "Oktober"
#: lib/bds/rendering/labels.ex:21 #: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open calendar" msgid "Open calendar"
msgstr "Kalender öffnen" msgstr "Kalender öffnen"
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Pagination" msgid "Pagination"
msgstr "Seitennummerierung" msgstr "Seitennummerierung"
#: lib/bds/rendering/labels.ex:28 #: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "Suchen..." msgstr "Suchen..."
#: lib/bds/rendering/labels.ex:72 #: lib/bds/rendering/labels.ex:75
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "September" msgid "September"
msgstr "Sept." msgstr "Sept."
#: lib/bds/rendering/labels.ex:27 #: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Site search" msgid "Site search"
msgstr "Seitensuche" msgstr "Seitensuche"
#: lib/bds/rendering/labels.ex:14 #: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Taxonomy" msgid "Taxonomy"
msgstr "Taxonomie" msgstr "Taxonomie"
#: lib/bds/rendering/labels.ex:19 #: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "newer" msgid "newer"
msgstr "neuer" msgstr "neuer"
#: lib/bds/rendering/labels.ex:20 #: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "older" msgid "older"
msgstr "älter" msgstr "älter"
#: lib/bds/rendering/labels.ex:30 #: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Back to preview home" msgid "Back to preview home"
msgstr "Zurück zur Vorschau-Startseite" msgstr "Zurück zur Vorschau-Startseite"
#: lib/bds/rendering/labels.ex:29 #: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "The requested preview page could not be found." msgid "The requested preview page could not be found."
msgstr "Die angeforderte Vorschauseite konnte nicht gefunden werden." msgstr "Die angeforderte Vorschauseite konnte nicht gefunden werden."
#: lib/bds/rendering/labels.ex:32 #: lib/bds/rendering/labels.ex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Vimeo video" msgid "Vimeo video"
msgstr "Vimeo-Video" msgstr "Vimeo-Video"
#: lib/bds/rendering/labels.ex:31 #: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "YouTube video" msgid "YouTube video"
msgstr "YouTube-Video" msgstr "YouTube-Video"
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "No results found"
msgstr "Keine Ergebnisse gefunden"

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +1,161 @@
#: lib/bds/rendering/labels.ex:17 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:241 #: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:316 #: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:52 #: lib/bds/rendering/labels.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "April" msgid "April"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:24 #: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Archive calendar" msgid "Archive calendar"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:68 #: lib/bds/rendering/labels.ex:71
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "August" msgid "August"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:15 #: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Backlinks" msgid "Backlinks"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:23 #: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded." msgid "Calendar data could not be loaded."
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:25 #: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Close calendar" msgid "Close calendar"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:84 #: lib/bds/rendering/labels.ex:87
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "December" msgid "December"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:44 #: lib/bds/rendering/labels.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "February" msgid "February"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:40 #: lib/bds/rendering/labels.ex:43
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "January" msgid "January"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:64 #: lib/bds/rendering/labels.ex:67
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "July" msgid "July"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:60 #: lib/bds/rendering/labels.ex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "June" msgid "June"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:26 #: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Language" msgid "Language"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:16 #: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked from" msgid "Linked from"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:22 #: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Loading calendar…" msgid "Loading calendar…"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:48 #: lib/bds/rendering/labels.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "March" msgid "March"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:56 #: lib/bds/rendering/labels.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "May" msgid "May"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:80 #: lib/bds/rendering/labels.ex:83
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "November" msgid "November"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:76 #: lib/bds/rendering/labels.ex:79
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "October" msgid "October"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:21 #: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open calendar" msgid "Open calendar"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Pagination" msgid "Pagination"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:28 #: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:72 #: lib/bds/rendering/labels.ex:75
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "September" msgid "September"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:27 #: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Site search" msgid "Site search"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:14 #: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Taxonomy" msgid "Taxonomy"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:19 #: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "newer" msgid "newer"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:20 #: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "older" msgid "older"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:30 #: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Back to preview home" msgid "Back to preview home"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:29 #: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "The requested preview page could not be found." msgid "The requested preview page could not be found."
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:32 #: lib/bds/rendering/labels.ex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Vimeo video" msgid "Vimeo video"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:31 #: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "YouTube video" msgid "YouTube video"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "No results found"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +1,161 @@
#: lib/bds/rendering/labels.ex:17 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:241 #: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:316 #: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "Archivo" msgstr "Archivo"
#: lib/bds/rendering/labels.ex:52 #: lib/bds/rendering/labels.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "April" msgid "April"
msgstr "abril" msgstr "abril"
#: lib/bds/rendering/labels.ex:24 #: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Archive calendar" msgid "Archive calendar"
msgstr "Archivo" msgstr "Archivo"
#: lib/bds/rendering/labels.ex:68 #: lib/bds/rendering/labels.ex:71
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "August" msgid "August"
msgstr "agosto" msgstr "agosto"
#: lib/bds/rendering/labels.ex:15 #: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Backlinks" msgid "Backlinks"
msgstr "Retroenlaces" msgstr "Retroenlaces"
#: lib/bds/rendering/labels.ex:23 #: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded." msgid "Calendar data could not be loaded."
msgstr "No se pudieron cargar los datos del calendario." msgstr "No se pudieron cargar los datos del calendario."
#: lib/bds/rendering/labels.ex:25 #: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Close calendar" msgid "Close calendar"
msgstr "Cerrar calendario" msgstr "Cerrar calendario"
#: lib/bds/rendering/labels.ex:84 #: lib/bds/rendering/labels.ex:87
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "December" msgid "December"
msgstr "diciembre" msgstr "diciembre"
#: lib/bds/rendering/labels.ex:44 #: lib/bds/rendering/labels.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "February" msgid "February"
msgstr "febrero" msgstr "febrero"
#: lib/bds/rendering/labels.ex:40 #: lib/bds/rendering/labels.ex:43
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "January" msgid "January"
msgstr "enero" msgstr "enero"
#: lib/bds/rendering/labels.ex:64 #: lib/bds/rendering/labels.ex:67
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "July" msgid "July"
msgstr "julio" msgstr "julio"
#: lib/bds/rendering/labels.ex:60 #: lib/bds/rendering/labels.ex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "June" msgid "June"
msgstr "junio" msgstr "junio"
#: lib/bds/rendering/labels.ex:26 #: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: lib/bds/rendering/labels.ex:16 #: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked from" msgid "Linked from"
msgstr "Enlazado desde" msgstr "Enlazado desde"
#: lib/bds/rendering/labels.ex:22 #: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Loading calendar…" msgid "Loading calendar…"
msgstr "Cargando calendario…" msgstr "Cargando calendario…"
#: lib/bds/rendering/labels.ex:48 #: lib/bds/rendering/labels.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "March" msgid "March"
msgstr "marzo" msgstr "marzo"
#: lib/bds/rendering/labels.ex:56 #: lib/bds/rendering/labels.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "May" msgid "May"
msgstr "mayo" msgstr "mayo"
#: lib/bds/rendering/labels.ex:80 #: lib/bds/rendering/labels.ex:83
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "November" msgid "November"
msgstr "noviembre" msgstr "noviembre"
#: lib/bds/rendering/labels.ex:76 #: lib/bds/rendering/labels.ex:79
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "October" msgid "October"
msgstr "octubre" msgstr "octubre"
#: lib/bds/rendering/labels.ex:21 #: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open calendar" msgid "Open calendar"
msgstr "Abrir calendario" msgstr "Abrir calendario"
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Pagination" msgid "Pagination"
msgstr "Paginación" msgstr "Paginación"
#: lib/bds/rendering/labels.ex:28 #: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "Buscar..." msgstr "Buscar..."
#: lib/bds/rendering/labels.ex:72 #: lib/bds/rendering/labels.ex:75
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "September" msgid "September"
msgstr "septiembre" msgstr "septiembre"
#: lib/bds/rendering/labels.ex:27 #: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Site search" msgid "Site search"
msgstr "Buscar en el sitio" msgstr "Buscar en el sitio"
#: lib/bds/rendering/labels.ex:14 #: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Taxonomy" msgid "Taxonomy"
msgstr "Taxonomía" msgstr "Taxonomía"
#: lib/bds/rendering/labels.ex:19 #: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "newer" msgid "newer"
msgstr "más reciente" msgstr "más reciente"
#: lib/bds/rendering/labels.ex:20 #: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "older" msgid "older"
msgstr "más antiguo" msgstr "más antiguo"
#: lib/bds/rendering/labels.ex:30 #: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Back to preview home" msgid "Back to preview home"
msgstr "Volver al inicio de vista previa" msgstr "Volver al inicio de vista previa"
#: lib/bds/rendering/labels.ex:29 #: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "The requested preview page could not be found." msgid "The requested preview page could not be found."
msgstr "No se pudo encontrar la página de vista previa solicitada." msgstr "No se pudo encontrar la página de vista previa solicitada."
#: lib/bds/rendering/labels.ex:32 #: lib/bds/rendering/labels.ex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Vimeo video" msgid "Vimeo video"
msgstr "Vídeo de Vimeo" msgstr "Vídeo de Vimeo"
#: lib/bds/rendering/labels.ex:31 #: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "YouTube video" msgid "YouTube video"
msgstr "Vídeo de YouTube" msgstr "Vídeo de YouTube"
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "No results found"
msgstr "No se encontraron resultados"

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +1,161 @@
#: lib/bds/rendering/labels.ex:17 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:241 #: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:316 #: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "Archives" msgstr "Archives"
#: lib/bds/rendering/labels.ex:52 #: lib/bds/rendering/labels.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "April" msgid "April"
msgstr "avril" msgstr "avril"
#: lib/bds/rendering/labels.ex:24 #: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Archive calendar" msgid "Archive calendar"
msgstr "Archives" msgstr "Archives"
#: lib/bds/rendering/labels.ex:68 #: lib/bds/rendering/labels.ex:71
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "August" msgid "August"
msgstr "août" msgstr "août"
#: lib/bds/rendering/labels.ex:15 #: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Backlinks" msgid "Backlinks"
msgstr "Rétroliens" msgstr "Rétroliens"
#: lib/bds/rendering/labels.ex:23 #: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded." msgid "Calendar data could not be loaded."
msgstr "Impossible de charger les données du calendrier." msgstr "Impossible de charger les données du calendrier."
#: lib/bds/rendering/labels.ex:25 #: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Close calendar" msgid "Close calendar"
msgstr "Fermer le calendrier" msgstr "Fermer le calendrier"
#: lib/bds/rendering/labels.ex:84 #: lib/bds/rendering/labels.ex:87
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "December" msgid "December"
msgstr "décembre" msgstr "décembre"
#: lib/bds/rendering/labels.ex:44 #: lib/bds/rendering/labels.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "February" msgid "February"
msgstr "février" msgstr "février"
#: lib/bds/rendering/labels.ex:40 #: lib/bds/rendering/labels.ex:43
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "January" msgid "January"
msgstr "janvier" msgstr "janvier"
#: lib/bds/rendering/labels.ex:64 #: lib/bds/rendering/labels.ex:67
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "July" msgid "July"
msgstr "juillet" msgstr "juillet"
#: lib/bds/rendering/labels.ex:60 #: lib/bds/rendering/labels.ex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "June" msgid "June"
msgstr "juin" msgstr "juin"
#: lib/bds/rendering/labels.ex:26 #: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Language" msgid "Language"
msgstr "Langue" msgstr "Langue"
#: lib/bds/rendering/labels.ex:16 #: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked from" msgid "Linked from"
msgstr "Lié depuis" msgstr "Lié depuis"
#: lib/bds/rendering/labels.ex:22 #: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Loading calendar…" msgid "Loading calendar…"
msgstr "Chargement du calendrier…" msgstr "Chargement du calendrier…"
#: lib/bds/rendering/labels.ex:48 #: lib/bds/rendering/labels.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "March" msgid "March"
msgstr "mars" msgstr "mars"
#: lib/bds/rendering/labels.ex:56 #: lib/bds/rendering/labels.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "May" msgid "May"
msgstr "mai" msgstr "mai"
#: lib/bds/rendering/labels.ex:80 #: lib/bds/rendering/labels.ex:83
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "November" msgid "November"
msgstr "novembre" msgstr "novembre"
#: lib/bds/rendering/labels.ex:76 #: lib/bds/rendering/labels.ex:79
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "October" msgid "October"
msgstr "octobre" msgstr "octobre"
#: lib/bds/rendering/labels.ex:21 #: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open calendar" msgid "Open calendar"
msgstr "Ouvrir le calendrier" msgstr "Ouvrir le calendrier"
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Pagination" msgid "Pagination"
msgstr "Navigation paginée" msgstr "Navigation paginée"
#: lib/bds/rendering/labels.ex:28 #: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "Rechercher..." msgstr "Rechercher..."
#: lib/bds/rendering/labels.ex:72 #: lib/bds/rendering/labels.ex:75
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "September" msgid "September"
msgstr "septembre" msgstr "septembre"
#: lib/bds/rendering/labels.ex:27 #: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Site search" msgid "Site search"
msgstr "Recherche du site" msgstr "Recherche du site"
#: lib/bds/rendering/labels.ex:14 #: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Taxonomy" msgid "Taxonomy"
msgstr "Taxonomie" msgstr "Taxonomie"
#: lib/bds/rendering/labels.ex:19 #: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "newer" msgid "newer"
msgstr "plus récent" msgstr "plus récent"
#: lib/bds/rendering/labels.ex:20 #: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "older" msgid "older"
msgstr "plus ancien" msgstr "plus ancien"
#: lib/bds/rendering/labels.ex:30 #: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Back to preview home" msgid "Back to preview home"
msgstr "Retour à laccueil de laperçu" msgstr "Retour à laccueil de laperçu"
#: lib/bds/rendering/labels.ex:29 #: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "The requested preview page could not be found." msgid "The requested preview page could not be found."
msgstr "La page daperçu demandée est introuvable." msgstr "La page daperçu demandée est introuvable."
#: lib/bds/rendering/labels.ex:32 #: lib/bds/rendering/labels.ex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Vimeo video" msgid "Vimeo video"
msgstr "Vidéo Vimeo" msgstr "Vidéo Vimeo"
#: lib/bds/rendering/labels.ex:31 #: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "YouTube video" msgid "YouTube video"
msgstr "Vidéo YouTube" msgstr "Vidéo YouTube"
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "No results found"
msgstr "Aucun résultat trouvé"

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +1,161 @@
#: lib/bds/rendering/labels.ex:17 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:241 #: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:316 #: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "Archivio" msgstr "Archivio"
#: lib/bds/rendering/labels.ex:52 #: lib/bds/rendering/labels.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "April" msgid "April"
msgstr "aprile" msgstr "aprile"
#: lib/bds/rendering/labels.ex:24 #: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Archive calendar" msgid "Archive calendar"
msgstr "Archivio" msgstr "Archivio"
#: lib/bds/rendering/labels.ex:68 #: lib/bds/rendering/labels.ex:71
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "August" msgid "August"
msgstr "agosto" msgstr "agosto"
#: lib/bds/rendering/labels.ex:15 #: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Backlinks" msgid "Backlinks"
msgstr "Retrocollegamenti" msgstr "Retrocollegamenti"
#: lib/bds/rendering/labels.ex:23 #: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded." msgid "Calendar data could not be loaded."
msgstr "Impossibile caricare i dati del calendario." msgstr "Impossibile caricare i dati del calendario."
#: lib/bds/rendering/labels.ex:25 #: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Close calendar" msgid "Close calendar"
msgstr "Chiudi calendario" msgstr "Chiudi calendario"
#: lib/bds/rendering/labels.ex:84 #: lib/bds/rendering/labels.ex:87
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "December" msgid "December"
msgstr "dicembre" msgstr "dicembre"
#: lib/bds/rendering/labels.ex:44 #: lib/bds/rendering/labels.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "February" msgid "February"
msgstr "febbraio" msgstr "febbraio"
#: lib/bds/rendering/labels.ex:40 #: lib/bds/rendering/labels.ex:43
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "January" msgid "January"
msgstr "gennaio" msgstr "gennaio"
#: lib/bds/rendering/labels.ex:64 #: lib/bds/rendering/labels.ex:67
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "July" msgid "July"
msgstr "luglio" msgstr "luglio"
#: lib/bds/rendering/labels.ex:60 #: lib/bds/rendering/labels.ex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "June" msgid "June"
msgstr "giugno" msgstr "giugno"
#: lib/bds/rendering/labels.ex:26 #: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Language" msgid "Language"
msgstr "Lingua" msgstr "Lingua"
#: lib/bds/rendering/labels.ex:16 #: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked from" msgid "Linked from"
msgstr "Collegato da" msgstr "Collegato da"
#: lib/bds/rendering/labels.ex:22 #: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Loading calendar…" msgid "Loading calendar…"
msgstr "Caricamento calendario…" msgstr "Caricamento calendario…"
#: lib/bds/rendering/labels.ex:48 #: lib/bds/rendering/labels.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "March" msgid "March"
msgstr "marzo" msgstr "marzo"
#: lib/bds/rendering/labels.ex:56 #: lib/bds/rendering/labels.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "May" msgid "May"
msgstr "maggio" msgstr "maggio"
#: lib/bds/rendering/labels.ex:80 #: lib/bds/rendering/labels.ex:83
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "November" msgid "November"
msgstr "novembre" msgstr "novembre"
#: lib/bds/rendering/labels.ex:76 #: lib/bds/rendering/labels.ex:79
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "October" msgid "October"
msgstr "ottobre" msgstr "ottobre"
#: lib/bds/rendering/labels.ex:21 #: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open calendar" msgid "Open calendar"
msgstr "Apri calendario" msgstr "Apri calendario"
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Pagination" msgid "Pagination"
msgstr "Paginazione" msgstr "Paginazione"
#: lib/bds/rendering/labels.ex:28 #: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "Cerca..." msgstr "Cerca..."
#: lib/bds/rendering/labels.ex:72 #: lib/bds/rendering/labels.ex:75
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "September" msgid "September"
msgstr "settembre" msgstr "settembre"
#: lib/bds/rendering/labels.ex:27 #: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Site search" msgid "Site search"
msgstr "Ricerca nel sito" msgstr "Ricerca nel sito"
#: lib/bds/rendering/labels.ex:14 #: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Taxonomy" msgid "Taxonomy"
msgstr "Tassonomia" msgstr "Tassonomia"
#: lib/bds/rendering/labels.ex:19 #: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "newer" msgid "newer"
msgstr "più recente" msgstr "più recente"
#: lib/bds/rendering/labels.ex:20 #: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "older" msgid "older"
msgstr "più vecchio" msgstr "più vecchio"
#: lib/bds/rendering/labels.ex:30 #: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Back to preview home" msgid "Back to preview home"
msgstr "Torna alla home di anteprima" msgstr "Torna alla home di anteprima"
#: lib/bds/rendering/labels.ex:29 #: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "The requested preview page could not be found." msgid "The requested preview page could not be found."
msgstr "La pagina di anteprima richiesta non è stata trovata." msgstr "La pagina di anteprima richiesta non è stata trovata."
#: lib/bds/rendering/labels.ex:32 #: lib/bds/rendering/labels.ex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Vimeo video" msgid "Vimeo video"
msgstr "Video Vimeo" msgstr "Video Vimeo"
#: lib/bds/rendering/labels.ex:31 #: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "YouTube video" msgid "YouTube video"
msgstr "Video YouTube" msgstr "Video YouTube"
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "No results found"
msgstr "Nessun risultato trovato"

File diff suppressed because it is too large Load Diff

View File

@@ -11,159 +11,164 @@
msgid "" msgid ""
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:17 #: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:241 #: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:316 #: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive" msgid "Archive"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:52 #: lib/bds/rendering/labels.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "April" msgid "April"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:24 #: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archive calendar" msgid "Archive calendar"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:68 #: lib/bds/rendering/labels.ex:71
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "August" msgid "August"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:15 #: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Backlinks" msgid "Backlinks"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:23 #: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded." msgid "Calendar data could not be loaded."
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:25 #: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Close calendar" msgid "Close calendar"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:84 #: lib/bds/rendering/labels.ex:87
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "December" msgid "December"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:44 #: lib/bds/rendering/labels.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "February" msgid "February"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:40 #: lib/bds/rendering/labels.ex:43
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "January" msgid "January"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:64 #: lib/bds/rendering/labels.ex:67
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "July" msgid "July"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:60 #: lib/bds/rendering/labels.ex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "June" msgid "June"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:26 #: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Language" msgid "Language"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:16 #: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Linked from" msgid "Linked from"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:22 #: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Loading calendar…" msgid "Loading calendar…"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:48 #: lib/bds/rendering/labels.ex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "March" msgid "March"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:56 #: lib/bds/rendering/labels.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "May" msgid "May"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:80 #: lib/bds/rendering/labels.ex:83
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "November" msgid "November"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:76 #: lib/bds/rendering/labels.ex:79
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "October" msgid "October"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:21 #: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open calendar" msgid "Open calendar"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:18 #: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Pagination" msgid "Pagination"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:28 #: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:72 #: lib/bds/rendering/labels.ex:75
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "September" msgid "September"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:27 #: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Site search" msgid "Site search"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:14 #: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Taxonomy" msgid "Taxonomy"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:19 #: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "newer" msgid "newer"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:20 #: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "older" msgid "older"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:30 #: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Back to preview home" msgid "Back to preview home"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:29 #: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "The requested preview page could not be found." msgid "The requested preview page could not be found."
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:32 #: lib/bds/rendering/labels.ex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Vimeo video" msgid "Vimeo video"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:31 #: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "YouTube video" msgid "YouTube video"
msgstr "" msgstr ""
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "No results found"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
/* Styling for the self-contained PagefindUI search widget. */
.pagefind-ui {
display: block;
font-size: 0.95rem;
}
.pagefind-ui__form {
margin: 0 0 0.75rem 0;
}
.pagefind-ui__search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--pico-form-element-border-color, #ccc);
border-radius: 0.375rem;
font: inherit;
box-sizing: border-box;
}
.pagefind-ui__results {
display: block;
}
.pagefind-ui__message {
margin: 0.5rem 0;
opacity: 0.7;
}
.pagefind-ui__result-list {
list-style: none;
margin: 0;
padding: 0;
}
.pagefind-ui__result {
padding: 0.5rem 0;
border-bottom: 1px solid var(--pico-muted-border-color, #eee);
}
.pagefind-ui__result:last-child {
border-bottom: none;
}
.pagefind-ui__result-link {
display: block;
font-weight: 600;
text-decoration: none;
}
.pagefind-ui__result-link:hover {
text-decoration: underline;
}
.pagefind-ui__result-excerpt {
margin: 0.25rem 0 0 0;
opacity: 0.8;
line-height: 1.4;
}
.pagefind-ui__result-excerpt mark {
background: var(--pico-mark-background-color, #ffe08a);
color: inherit;
padding: 0 0.1em;
border-radius: 0.15em;
}

View File

@@ -0,0 +1,272 @@
/*
* Self-contained client-side search UI for generated blog output.
*
* Exposes a global `PagefindUI` constructor that the bundled
* `search-runtime.js` instantiates. It fetches the per-language fragment
* index (`index.json`) co-located with this script, performs full-text
* matching over the indexed post fragments, and renders ranked results.
*
* No external/CDN dependencies — everything needed ships in this file.
*/
(function () {
"use strict";
// Resolve the sibling index.json relative to this script's own URL so the
// same bundle works for every language directory (e.g. /pagefind/,
// /de/pagefind/) without baking the path in at generation time.
var scriptSrc = (document.currentScript && document.currentScript.src) || "";
function resolveIndexUrl(src) {
try {
return new URL("index.json", src).href;
} catch (err) {
return "index.json";
}
}
function tokenize(value) {
return (value || "")
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter(function (token) {
return token.length > 0;
});
}
function PagefindUI(options) {
options = options || {};
this.element =
typeof options.element === "string"
? document.querySelector(options.element)
: options.element;
if (!this.element) {
return;
}
var translations = options.translations || {};
this.placeholder = translations.placeholder || "Search";
this.zeroResults = translations.zero_results || "No results found";
this.indexUrl = options.indexUrl || resolveIndexUrl(scriptSrc);
this.pages = null;
this.loadPromise = null;
this.render();
}
PagefindUI.prototype.render = function () {
var self = this;
this.element.classList.add("pagefind-ui");
this.element.innerHTML = "";
var form = document.createElement("form");
form.className = "pagefind-ui__form";
form.setAttribute("role", "search");
form.addEventListener("submit", function (event) {
event.preventDefault();
});
var input = document.createElement("input");
input.type = "search";
input.className = "pagefind-ui__search-input";
input.setAttribute("autocomplete", "off");
input.placeholder = this.placeholder;
input.setAttribute("aria-label", this.placeholder);
var results = document.createElement("div");
results.className = "pagefind-ui__results";
form.appendChild(input);
this.element.appendChild(form);
this.element.appendChild(results);
this.input = input;
this.results = results;
var debounce = null;
input.addEventListener("input", function () {
window.clearTimeout(debounce);
debounce = window.setTimeout(function () {
self.search(input.value);
}, 120);
});
};
PagefindUI.prototype.load = function () {
if (this.pages) {
return Promise.resolve(this.pages);
}
if (this.loadPromise) {
return this.loadPromise;
}
var self = this;
this.loadPromise = fetch(this.indexUrl, { credentials: "same-origin" })
.then(function (response) {
if (!response.ok) {
throw new Error("pagefind index request failed: " + response.status);
}
return response.json();
})
.then(function (data) {
self.pages = (data && data.pages) || [];
return self.pages;
})
.catch(function () {
self.pages = [];
return self.pages;
});
return this.loadPromise;
};
PagefindUI.prototype.search = function (query) {
var self = this;
var terms = tokenize(query);
if (terms.length === 0) {
this.results.innerHTML = "";
return;
}
this.load().then(function (pages) {
var matches = [];
for (var i = 0; i < pages.length; i++) {
var page = pages[i];
var title = (page.title || "").toLowerCase();
var body = (page.text || "").toLowerCase();
var score = 0;
var matchedAll = true;
for (var t = 0; t < terms.length; t++) {
var term = terms[t];
var bodyHits = body.split(term).length - 1;
var titleHits = title.split(term).length - 1;
if (bodyHits === 0 && titleHits === 0) {
matchedAll = false;
break;
}
// Title matches are weighted more heavily than body matches.
score += bodyHits + titleHits * 5;
}
if (matchedAll) {
matches.push({ page: page, score: score });
}
}
matches.sort(function (a, b) {
return b.score - a.score;
});
self.renderResults(matches.slice(0, 10), terms);
});
};
PagefindUI.prototype.renderResults = function (matches, terms) {
this.results.innerHTML = "";
if (matches.length === 0) {
var message = document.createElement("p");
message.className = "pagefind-ui__message";
message.textContent = this.zeroResults;
this.results.appendChild(message);
return;
}
var list = document.createElement("ol");
list.className = "pagefind-ui__result-list";
for (var i = 0; i < matches.length; i++) {
var page = matches[i].page;
var item = document.createElement("li");
item.className = "pagefind-ui__result";
var link = document.createElement("a");
link.className = "pagefind-ui__result-link";
link.href = page.url;
link.textContent = page.title || page.url;
var excerpt = document.createElement("p");
excerpt.className = "pagefind-ui__result-excerpt";
buildExcerpt(excerpt, page.text || "", terms);
item.appendChild(link);
item.appendChild(excerpt);
list.appendChild(item);
}
this.results.appendChild(list);
};
// Build an excerpt around the first matching term and highlight all terms
// with <mark>, using safe text nodes (never innerHTML on untrusted text).
function buildExcerpt(target, text, terms) {
var lower = text.toLowerCase();
var firstHit = -1;
for (var t = 0; t < terms.length; t++) {
var idx = lower.indexOf(terms[t]);
if (idx !== -1 && (firstHit === -1 || idx < firstHit)) {
firstHit = idx;
}
}
var start = firstHit === -1 ? 0 : Math.max(0, firstHit - 60);
var snippet = text.slice(start, start + 220);
if (start > 0) {
snippet = "…" + snippet;
}
if (start + 220 < text.length) {
snippet = snippet + "…";
}
highlightInto(target, snippet, terms);
}
function highlightInto(target, snippet, terms) {
var escaped = terms
.map(function (term) {
return term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
})
.filter(function (term) {
return term.length > 0;
});
if (escaped.length === 0) {
target.appendChild(document.createTextNode(snippet));
return;
}
var pattern = new RegExp("(" + escaped.join("|") + ")", "giu");
var lastIndex = 0;
var match;
while ((match = pattern.exec(snippet)) !== null) {
if (match.index > lastIndex) {
target.appendChild(
document.createTextNode(snippet.slice(lastIndex, match.index))
);
}
var mark = document.createElement("mark");
mark.textContent = match[0];
target.appendChild(mark);
lastIndex = match.index + match[0].length;
// Guard against zero-length matches looping forever.
if (match.index === pattern.lastIndex) {
pattern.lastIndex++;
}
}
if (lastIndex < snippet.length) {
target.appendChild(document.createTextNode(snippet.slice(lastIndex)));
}
}
window.PagefindUI = PagefindUI;
})();

View File

@@ -15,11 +15,12 @@
} }
initialized = true; initialized = true;
var placeholder = root.getAttribute('data-search-placeholder') || 'Search...'; var placeholder = root.getAttribute('data-search-placeholder') || 'Search...';
var zeroResults = root.getAttribute('data-search-no-results') || 'No results found';
new PagefindUI({ new PagefindUI({
element: root, element: root,
showSubResults: true, showSubResults: true,
showImages: false, showImages: false,
translations: { placeholder: placeholder } translations: { placeholder: placeholder, zero_results: zeroResults }
}); });
var input = root.querySelector('input'); var input = root.querySelector('input');
if (input) { if (input) {

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

@@ -0,0 +1,33 @@
defmodule BDS.Repo.Migrations.ConvertEmbeddingVectorToBlob do
use Ecto.Migration
# Embedding vectors are now persisted as a packed little-endian Float32 BLOB
# (VectorCacheInDb invariant) instead of JSON text. The table is a rebuildable
# cache and the previous lexical vectors are incompatible with the neural
# model, so we drop and recreate it; rows are re-embedded on next index.
def up do
drop table(:embedding_keys)
create_embedding_keys(:binary)
end
def down do
drop table(:embedding_keys)
create_embedding_keys(:text)
end
defp create_embedding_keys(vector_type) do
create table(:embedding_keys, primary_key: false) do
add :label, :integer, primary_key: true
add :post_id, references(:posts, column: :id, type: :string, on_delete: :delete_all),
null: false
add :project_id, references(:projects, type: :string, on_delete: :delete_all), null: false
add :content_hash, :string, null: false
add :vector, vector_type
end
create index(:embedding_keys, [:post_id])
create index(:embedding_keys, [:project_id])
end
end

View File

@@ -15,7 +15,7 @@
</svg> </svg>
</button> </button>
<div class="blog-search-panel" data-blog-search-panel hidden> <div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}"></div> <div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}" data-search-no-results="{{ labels.search_no_results }}"></div>
</div> </div>
</div> </div>
</nav> </nav>
@@ -35,7 +35,7 @@
</svg> </svg>
</button> </button>
<div class="blog-search-panel" data-blog-search-panel hidden> <div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}"></div> <div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}" data-search-no-results="{{ labels.search_no_results }}"></div>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@@ -3676,9 +3676,10 @@ button svg, button svg * {
.chat-message { .chat-message {
display: flex; display: flex;
max-width: 100%; max-width: 100%;
margin-bottom: 16px;
} }
.chat-message.user { .chat-message.user {
justify-content: flex-end; flex-direction: row-reverse;
} }
.chat-message-content { .chat-message-content {
max-width: min(760px, 100%); max-width: min(760px, 100%);
@@ -3689,10 +3690,11 @@ button svg, button svg * {
color: var(--vscode-editor-foreground); color: var(--vscode-editor-foreground);
} }
.chat-panel .chat-message.user .chat-message-content { .chat-panel .chat-message.user .chat-message-content {
background: transparent; background: var(--vscode-button-background, var(--accent-color, #007acc));
color: var(--vscode-list-activeSelectionForeground); color: var(--vscode-button-foreground, var(--vscode-list-activeSelectionForeground, #ffffff));
border: 0; border: 1px solid var(--vscode-button-background, var(--accent-color, #007acc));
padding: 6px 12px; border-radius: 6px;
padding: 12px 14px;
line-height: 1.35; line-height: 1.35;
} }
.chat-tool-surface-table { .chat-tool-surface-table {
@@ -3711,18 +3713,276 @@ button svg, button svg * {
border-radius: 4px; border-radius: 4px;
background: var(--vscode-textCodeBlock-background); background: var(--vscode-textCodeBlock-background);
} }
.chat-inline-surface {
margin: 10px 0;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-sideBar-background);
overflow: hidden;
}
.chat-inline-surface-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
list-style: none;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-inline-surface-header::-webkit-details-marker {
display: none;
}
.chat-inline-surface-header::marker {
content: "";
}
.chat-inline-surface-icon {
flex: 0 0 auto;
font-size: 14px;
line-height: 1;
opacity: 0.7;
}
.chat-inline-surface-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
color: var(--vscode-editor-foreground);
}
.chat-inline-surface-dismiss {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--vscode-descriptionForeground);
font-size: 16px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.chat-inline-surface:hover .chat-inline-surface-dismiss {
opacity: 1;
}
.chat-inline-surface-dismiss:hover {
background: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-editor-foreground);
}
.chat-inline-surface-body {
padding: 0 12px 12px;
}
.chat-inline-surface-body h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--vscode-editor-foreground);
}
.chat-surface-chart-type {
margin: 0 0 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
display: none;
}
.chat-surface-chart-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-surface-chart-row {
display: flex;
flex-direction: column;
gap: 2px;
}
.chat-surface-chart-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 12px;
}
.chat-surface-chart-meta span:first-child {
color: var(--vscode-editor-foreground);
}
.chat-surface-chart-meta span:last-child {
color: var(--vscode-descriptionForeground);
font-variant-numeric: tabular-nums;
}
.chat-surface-chart-bar {
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.chat-surface-chart-bar span {
display: block;
height: 100%;
border-radius: 3px;
background: var(--accent-color);
min-width: 0;
transition: width 0.3s ease;
}
.chat-surface-card {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-surface-subtitle {
margin: 0;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-surface-body {
margin: 0;
font-size: 13px;
line-height: 1.45;
}
.chat-surface-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.chat-surface-action-button {
padding: 4px 12px;
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
font-size: 12px;
cursor: pointer;
}
.chat-surface-action-button:hover {
background: var(--vscode-list-hoverBackground);
}
.chat-surface-metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.chat-surface-metric-label {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-surface-metric-value {
font-size: 22px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--vscode-editor-foreground);
}
.chat-surface-list {
margin: 0;
padding: 0 0 0 18px;
font-size: 13px;
line-height: 1.5;
}
.chat-surface-mindmap {
margin: 0;
padding: 0;
list-style: none;
font-size: 13px;
}
.chat-surface-mindmap li {
padding: 4px 0;
border-bottom: 1px solid var(--vscode-panel-border);
}
.chat-surface-mindmap li:last-child {
border-bottom: none;
}
.chat-surface-mindmap strong {
display: block;
color: var(--vscode-editor-foreground);
}
.chat-surface-mindmap-children {
display: block;
font-size: 12px;
color: var(--vscode-descriptionForeground);
padding-left: 12px;
}
.chat-surface-tabs {
display: flex;
flex-direction: column;
}
.chat-surface-tab-list {
display: flex;
gap: 0;
border-bottom: 1px solid var(--vscode-panel-border);
}
.chat-surface-tab-button {
padding: 6px 12px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--vscode-descriptionForeground);
font-size: 12px;
cursor: pointer;
}
.chat-surface-tab-button.active {
color: var(--vscode-editor-foreground);
border-bottom-color: var(--accent-color);
}
.chat-surface-tab-button:hover:not(.active) {
color: var(--vscode-editor-foreground);
}
.chat-surface-tab-panel {
padding: 10px 0 0;
}
.chat-surface-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-surface-form-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.chat-surface-form-field input, .chat-surface-form-field textarea, .chat-surface-form-field select {
padding: 5px 8px;
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font: inherit;
}
.chat-surface-form-field textarea {
min-height: 60px;
resize: vertical;
}
.chat-surface-form-checkbox {
display: flex;
align-items: center;
}
.chat-surface-text {
font-size: 13px;
line-height: 1.45;
white-space: pre-wrap;
}
.chat-tool-surface-table-wrap {
overflow-x: auto;
}
.chat-panel .chat-input-container { .chat-panel .chat-input-container {
--chat-input-line-height: 20px; --chat-input-line-height: 22px;
--chat-input-min-height: 20px; --chat-input-min-height: 24px;
border-top: 1px solid var(--vscode-panel-border); border-top: 1px solid var(--vscode-panel-border);
padding: 8px 16px; padding: 12px 16px;
background: var(--vscode-sideBar-background); background: var(--vscode-sideBar-background);
} }
.chat-panel .chat-input-wrapper { .chat-panel .chat-input-wrapper {
min-height: 30px; min-height: 40px;
border: 1px solid var(--vscode-input-border); border: 1px solid var(--vscode-input-border);
border-radius: 6px; border-radius: 8px;
padding: 4px 6px; padding: 6px 8px;
background: var(--vscode-input-background); background: var(--vscode-input-background);
} }
.chat-panel .chat-input-wrapper:focus-within { .chat-panel .chat-input-wrapper:focus-within {
@@ -3739,10 +3999,14 @@ button svg, button svg * {
max-height: 160px; max-height: 160px;
resize: vertical; resize: vertical;
border: 0; border: 0;
outline: none;
background: transparent; background: transparent;
color: var(--vscode-input-foreground); color: var(--vscode-input-foreground);
overflow-y: hidden; overflow-y: hidden;
} }
.chat-panel .chat-input:focus {
outline: none;
}
.chat-panel .chat-input::placeholder { .chat-panel .chat-input::placeholder {
color: var(--vscode-input-placeholderForeground); color: var(--vscode-input-placeholderForeground);
} }
@@ -3812,6 +4076,8 @@ button svg, button svg * {
pointer-events: auto; pointer-events: auto;
} }
.ai-suggestions-modal, .insert-modal, .language-picker-modal, .confirm-delete-modal, .confirm-dialog, .gallery-overlay-content { .ai-suggestions-modal, .insert-modal, .language-picker-modal, .confirm-delete-modal, .confirm-dialog, .gallery-overlay-content {
position: relative;
z-index: 1;
background: #1e1e1e; background: #1e1e1e;
border: 1px solid #3c3c3c; border: 1px solid #3c3c3c;
border-radius: 8px; border-radius: 8px;
@@ -5071,6 +5337,472 @@ button.import-taxonomy-pill {
align-items: stretch; align-items: stretch;
} }
} }
.misc-editor-shell {
background: var(--vscode-editor-background);
}
.misc-editor-header {
padding: 12px 16px 8px;
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-tab-activeBackground);
}
.misc-editor-header h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.misc-editor-header p {
margin: 2px 0 0;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.misc-editor-actions {
flex-shrink: 0;
}
.misc-editor-summary {
padding: 8px 16px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.misc-editor-content {
padding: 16px;
}
.misc-summary-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
color: var(--vscode-foreground);
}
.misc-summary-pill span {
color: var(--vscode-descriptionForeground);
}
.misc-summary-pill strong {
font-weight: 600;
}
.misc-card {
padding: 14px 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: var(--vscode-editor-background);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
}
.misc-card h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
.misc-card p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.misc-card ul {
margin: 6px 0 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.6;
}
.misc-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.misc-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.misc-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 4px;
}
.misc-list-item:hover {
background: var(--vscode-list-hoverBackground);
}
.duplicate-pair-row label {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
cursor: pointer;
}
.duplicate-pair-row .linkish {
background: none;
border: none;
color: var(--vscode-textLink-foreground, #3794ff);
cursor: pointer;
padding: 0;
font: inherit;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.14em;
}
.duplicate-pair-row .linkish:hover {
color: var(--vscode-foreground);
}
.metadata-diff-tool {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-diff-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.metadata-diff-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--vscode-tab-inactiveForeground, var(--vscode-descriptionForeground));
font: inherit;
font-size: 12px;
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.metadata-diff-tab:hover {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
}
.metadata-diff-tab.active {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
border-bottom-color: var(--vscode-focusBorder, #007fd4);
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
background: var(--vscode-activityBarBadge-background, #007acc);
color: var(--vscode-activityBarBadge-foreground, #ffffff);
}
.metadata-diff-field-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.metadata-diff-field-pill {
display: flex;
align-items: stretch;
border-radius: 6px;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-input-background);
overflow: hidden;
transition: border-color 0.12s;
}
.metadata-diff-field-pill.active {
border-color: var(--vscode-focusBorder, #007fd4);
background: var(--vscode-focusBorder, #007fd4);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 12%, transparent);
}
}
.metadata-diff-field-pill-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: none;
background: transparent;
color: var(--vscode-foreground);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.metadata-diff-field-pill-toggle:hover {
background: var(--vscode-list-hoverBackground);
}
.field-pill-label {
font-weight: 500;
}
.field-pill-count {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
}
.metadata-diff-field-pill-actions {
display: flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-left: 1px solid var(--vscode-panel-border);
}
.metadata-diff-action-button {
font-size: 11px !important;
padding: 2px 8px !important;
min-height: 22px !important;
}
.metadata-diff-results {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-diff-empty p {
text-align: center;
padding: 20px 0;
}
.diff-item-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-item-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: var(--vscode-editor-background);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
overflow: hidden;
}
.diff-item-card.orphan-file {
border-left: 3px solid var(--vscode-editorWarning-foreground, #cca700);
}
.diff-item-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: var(--vscode-sideBar-background);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, transparent);
}
border-bottom: 1px solid var(--vscode-panel-border);
}
.diff-item-header strong {
font-size: 13px;
font-weight: 600;
color: var(--vscode-foreground);
}
.diff-item-meta {
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-top: 2px;
}
.diff-item-fields {
padding: 8px 14px;
}
.diff-field-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid var(--vscode-panel-border);
@supports (color: color-mix(in lab, red, red)) {
border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border) 50%, transparent);
}
}
.diff-field-row:last-child {
border-bottom: none;
}
.diff-field-name {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
padding-top: 3px;
}
.diff-field-values {
display: flex;
flex-direction: column;
gap: 3px;
}
.diff-field-value {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 1.4;
word-break: break-word;
}
.diff-field-value.db-value {
color: var(--vscode-foreground);
}
.diff-field-value.file-value {
color: var(--vscode-foreground);
opacity: 0.82;
}
.diff-source-label {
flex-shrink: 0;
min-width: 28px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: 3px;
}
.db-value .diff-source-label {
background: var(--vscode-focusBorder, #007fd4);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 22%, transparent);
}
color: var(--vscode-focusBorder, #007fd4);
}
.file-value .diff-source-label {
background: var(--vscode-testing-iconPassed, #73c991);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 22%, transparent);
}
color: var(--vscode-testing-iconPassed, #73c991);
}
.orphan-files-section {
border: 1px solid var(--vscode-editorWarning-foreground, #cca700);
@supports (color: color-mix(in lab, red, red)) {
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 35%, transparent);
}
border-radius: 8px;
padding: 14px 16px;
background: var(--vscode-editorWarning-foreground, #cca700);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 5%, var(--vscode-editor-background));
}
}
.orphan-files-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.orphan-files-header h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.orphan-files-actions {
display: flex;
align-items: center;
gap: 8px;
}
.orphan-path span {
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.translation-validation-view {
display: flex;
flex-direction: column;
gap: 16px;
}
.translation-validation-summary {
padding: 10px 14px;
border-radius: 6px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.translation-validation-summary p {
margin: 0;
}
.translation-validation-section h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
.translation-validation-empty {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.translation-validation-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.translation-validation-card {
padding: 10px 14px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-editor-background);
@supports (color: color-mix(in lab, red, red)) {
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
}
.translation-validation-card-db {
border-left: 3px solid var(--vscode-focusBorder, #007fd4);
}
.translation-validation-card-file {
border-left: 3px solid var(--vscode-testing-iconPassed, #73c991);
}
.translation-validation-card-title {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
}
.translation-validation-card-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
margin: 0;
font-size: 12px;
}
.translation-validation-card-meta dt {
color: var(--vscode-descriptionForeground);
font-weight: 500;
}
.translation-validation-card-meta dd {
margin: 0;
}
.translation-validation-actions {
display: flex;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--vscode-panel-border);
}
.git-diff-view {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
height: 100%;
}
.git-diff-empty {
color: var(--vscode-descriptionForeground);
text-align: center;
padding: 24px 0;
}
.git-diff-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.git-diff-toolbar label {
font-size: 12px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
flex-shrink: 0;
}
.git-diff-toolbar select {
flex: 1;
min-width: 0;
}
.git-diff-editor {
flex: 1;
min-height: 0;
overflow: hidden;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
}
@layer components { @layer components {
.ui-button { .ui-button {
display: inline-flex; display: inline-flex;

View File

@@ -9041,6 +9041,11 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}"
runMenuRuntimeCommand(String(action)); runMenuRuntimeCommand(String(action));
} }
}); });
this.handleEvent("url-state", ({ path }) => {
if (path && window.location.pathname + window.location.search !== path) {
window.history.replaceState({}, "", path);
}
});
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction); window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.addEventListener("keydown", this.handleShortcutKeyDown, true); window.addEventListener("keydown", this.handleShortcutKeyDown, true);
this.el.addEventListener("load", this.handleThumbnailLoad, true); this.el.addEventListener("load", this.handleThumbnailLoad, true);

View File

@@ -19,7 +19,7 @@ value PostEditorView {
metadata: PostEditorMetadata metadata: PostEditorMetadata
metadata_expanded: Boolean -- starts expanded when title is empty metadata_expanded: Boolean -- starts expanded when title is empty
excerpt_expanded: Boolean excerpt_expanded: Boolean
editor_mode: String -- visual | markdown | preview editor_mode: String -- markdown | preview
footer: PostEditorFooter footer: PostEditorFooter
} }
@@ -152,15 +152,15 @@ surface PostEditorSurface {
-- Collapsible section with textarea (4 rows). -- Collapsible section with textarea (4 rows).
@guarantee EditorBodyToolbar @guarantee EditorBodyToolbar
-- Toolbar: "Content" label, mode toggle (Visual/Markdown/Preview), -- Toolbar: "Content" label, mode toggle (Markdown/Preview),
-- action buttons (markdown mode only): Gallery (with media count), -- action buttons (markdown mode only): Gallery (with media count),
-- Insert Post Link, Insert Media. -- Insert Post Link, Insert Media.
@guarantee EditorModes @guarantee EditorModes
-- Visual: rich-text WYSIWYG editor.
-- Markdown: code editor with markdown-with-macros language, -- Markdown: code editor with markdown-with-macros language,
-- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font. -- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font.
-- Preview: iframe showing rendered preview. -- Preview: iframe showing rendered preview.
-- Note: visual/WYSIWYG mode not implemented; "visual" normalizes to markdown.
@guarantee DragDropImages @guarantee DragDropImages
-- Drop image file onto editor area triggers import chain. -- Drop image file onto editor area triggers import chain.
@@ -193,8 +193,8 @@ invariant PostDirtyTracking {
} }
invariant PostEditorModePersistence { invariant PostEditorModePersistence {
-- Editor mode (visual/markdown/preview) persists per session. -- Editor mode (markdown/preview) persists per session.
-- Default mode comes from editor settings. -- Default mode comes from editor settings (markdown).
} }
-- ─── Post editor actions ──────────────────────────────────── -- ─── Post editor actions ────────────────────────────────────

View File

@@ -29,11 +29,12 @@ value SettingsProjectSection {
blog_languages: List<String> -- checkboxes (main language disabled) blog_languages: List<String> -- checkboxes (main language disabled)
default_author: String -- text input default_author: String -- text input
max_posts_per_page: Integer -- number input (1-500, default 50) max_posts_per_page: Integer -- number input (1-500, default 50)
image_import_concurrency: Integer -- number input (1-8, default 4)
blogmark_category: String -- select from categories blogmark_category: String -- select from categories
} }
value SettingsEditorSection { value SettingsEditorSection {
default_mode: String -- select: wysiwyg | markdown | preview default_mode: String -- select: markdown | preview
diff_view_style: String -- select: inline | side-by-side diff_view_style: String -- select: inline | side-by-side
wrap_long_lines: Boolean -- checkbox wrap_long_lines: Boolean -- checkbox
hide_unchanged_regions: Boolean -- checkbox hide_unchanged_regions: Boolean -- checkbox
@@ -92,6 +93,9 @@ invariant SettingsMCPAgents {
config { config {
settings_max_posts_per_page: Integer = 500 settings_max_posts_per_page: Integer = 500
settings_default_posts_per_page: Integer = 50 settings_default_posts_per_page: Integer = 50
settings_image_import_concurrency_default: Integer = 4
settings_image_import_concurrency_min: Integer = 1
settings_image_import_concurrency_max: Integer = 8
settings_system_prompt_rows: Integer = 12 settings_system_prompt_rows: Integer = 12
} }
@@ -131,6 +135,7 @@ surface SettingsViewSurface {
-- Section 1: Project Name, Description (textarea 3 rows), Data Path (text + Browse + Reset), -- Section 1: Project Name, Description (textarea 3 rows), Data Path (text + Browse + Reset),
-- Public URL, Main Language (select), Blog Languages (checkbox grid, main disabled), -- Public URL, Main Language (select), Blog Languages (checkbox grid, main disabled),
-- Default Author, Max Posts Per Page (number 1-500), -- Default Author, Max Posts Per Page (number 1-500),
-- Image Import Concurrency (number 1-8, default 4),
-- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save button. -- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save button.
@guarantee BookmarkletCopy @guarantee BookmarkletCopy
@@ -138,7 +143,7 @@ surface SettingsViewSurface {
-- Bookmarklet uses project's publicUrl to construct POST endpoint. -- Bookmarklet uses project's publicUrl to construct POST endpoint.
@guarantee EditorSection @guarantee EditorSection
-- Section 2: Default Editor Mode (select: WYSIWYG/Markdown/Preview), -- Section 2: Default Editor Mode (select: Markdown/Preview),
-- Diff View Style (select: Inline/Side-by-side), -- Diff View Style (select: Inline/Side-by-side),
-- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox). -- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox).

View File

@@ -48,6 +48,9 @@ value EmbeddingModel {
-- Lazy-loaded: pipeline created on first embedding request, not at startup -- Lazy-loaded: pipeline created on first embedding request, not at startup
-- Text preprocessing: prefix all input with "query: " (e5 convention) -- Text preprocessing: prefix all input with "query: " (e5 convention)
-- Pooling: mean pooling + L2 normalization -- Pooling: mean pooling + L2 normalization
-- Loaded on-device via Bumblebee+EXLA; the canonical e5 weights come from
-- the "intfloat/multilingual-e5-small" repository, surfaced under the
-- "Xenova/multilingual-e5-small" model_id identifier.
model_id: String -- "Xenova/multilingual-e5-small" model_id: String -- "Xenova/multilingual-e5-small"
dimensions: Integer -- 384 dimensions: Integer -- 384
} }
@@ -60,7 +63,7 @@ value EmbeddingVector {
-- ─── Entities ─────────────────────────────────────────────── -- ─── Entities ───────────────────────────────────────────────
entity EmbeddingKey { entity EmbeddingKey {
label: Integer -- HNSW label for USearch label: Integer -- HNSW node label / id
post: post/Post post: post/Post
content_hash: String -- SHA-256 of "{title}\n\n{content}" content_hash: String -- SHA-256 of "{title}\n\n{content}"
vector: EmbeddingVector vector: EmbeddingVector
@@ -72,9 +75,11 @@ entity DismissedDuplicatePair {
-- IDs stored in canonical order (sorted) for dedup -- IDs stored in canonical order (sorted) for dedup
} }
-- ─── USearch HNSW Index ───────────────────────────────────── -- ─── HNSW Index ─────────────────────────────────────────────
config { config {
-- HNSW approximate-nearest-neighbour index (hnswlib). USearch has no Elixir
-- binding; hnswlib provides the same HNSW algorithm and parameters.
model_id: String = "Xenova/multilingual-e5-small" model_id: String = "Xenova/multilingual-e5-small"
embedding_dimensions: Integer = 384 embedding_dimensions: Integer = 384
hnsw_metric: String = "cosine" hnsw_metric: String = "cosine"
@@ -83,7 +88,10 @@ config {
hnsw_expansion_search: Integer = 64 -- efSearch hnsw_expansion_search: Integer = 64 -- efSearch
debounce_persist: Duration = 5.seconds debounce_persist: Duration = 5.seconds
-- Index file: {userData}/projects/{projectId}/embeddings.usearch -- Index file: {userData}/projects/{projectId}/embeddings.usearch
-- Key mapping is persisted alongside the embedding records -- Key mapping (label → post_id) persisted in a sidecar (.meta.json) next
-- to the index file, plus the source-of-truth rows in embedding_keys
batch_size: Integer = 16 -- texts per batched inference run
sequence_length: Integer = 256 -- max tokens per input (truncated)
} }
-- ─── Gating ───────────────────────────────────────────────── -- ─── Gating ─────────────────────────────────────────────────
@@ -107,7 +115,7 @@ rule EmbedPost {
let existing = EmbeddingKey{post: post} let existing = EmbeddingKey{post: post}
if not exists existing or existing.content_hash != hash: if not exists existing or existing.content_hash != hash:
-- Compute embedding vector via local model -- Compute embedding vector via local model
-- Upsert into USearch index + embedding_keys DB table -- Upsert into HNSW index + embedding_keys DB table
-- Debounced index save (5s) -- Debounced index save (5s)
ensures: EmbeddingKeyUpdated(post) ensures: EmbeddingKeyUpdated(post)
} }
@@ -146,9 +154,9 @@ rule IndexUnindexed {
rule FindSimilar { rule FindSimilar {
when: FindSimilarRequested(post, limit) when: FindSimilarRequested(post, limit)
requires: semantic_similarity_enabled requires: semantic_similarity_enabled
-- HNSW approximate nearest neighbor search via USearch -- HNSW approximate nearest neighbor search (hnswlib)
-- Searches index for (limit + 1) neighbors, excludes self -- Searches index for (limit + 1) neighbors, excludes self
-- Converts USearch cosine distance to similarity: max(0, 1 - distance) -- Converts HNSW cosine distance to similarity: max(0, 1 - distance)
-- Returns ranked list sorted by descending similarity -- Returns ranked list sorted by descending similarity
ensures: SimilarPostsResult(post, ranked_matches) ensures: SimilarPostsResult(post, ranked_matches)
} }
@@ -157,7 +165,7 @@ rule ComputeSimilarities {
when: ComputeSimilaritiesRequested(source_post, target_post_ids) when: ComputeSimilaritiesRequested(source_post, target_post_ids)
requires: semantic_similarity_enabled requires: semantic_similarity_enabled
-- Exact pairwise cosine similarity between source vector and each target vector -- Exact pairwise cosine similarity between source vector and each target vector
-- Uses in-memory vector cache, NOT USearch search -- Uses in-memory vector cache, NOT the HNSW index
-- Returns map of post_id -> similarity score -- Returns map of post_id -> similarity score
-- Used by InsertPostLinkModal to rank FTS search results -- Used by InsertPostLinkModal to rank FTS search results
ensures: SimilarityScoresResult(source_post, scores) ensures: SimilarityScoresResult(source_post, scores)
@@ -202,7 +210,7 @@ invariant ContentHashSkipsUnchanged {
} }
invariant DebouncedPersistence { invariant DebouncedPersistence {
-- USearch index persistence is debounced at 5 seconds -- HNSW index persistence is debounced at 5 seconds
-- Prevents excessive disk I/O during bulk operations -- Prevents excessive disk I/O during bulk operations
-- Index also force-saved on project switch and app shutdown -- Index also force-saved on project switch and app shutdown
} }
@@ -213,6 +221,27 @@ invariant VectorCacheInDb {
-- Enables instant reload without re-embedding -- Enables instant reload without re-embedding
} }
invariant RealNeuralModel {
-- Embeddings MUST be produced by the actual ONNX neural model (multilingual-e5-small),
-- not by lexical approximations (TF-IDF, bag-of-words, hash projections).
-- Cross-language semantic similarity is a primary requirement:
-- posts in different languages about the same topic must produce similar vectors.
-- This is only achievable with the trained multilingual transformer model.
}
invariant NativeAcceleratedExecution {
-- Model execution MUST use the platform's native hardware acceleration
-- where available (GPU/Metal/Neural Engine on Apple Silicon, CUDA on
-- NVIDIA, etc.), and otherwise fall back to optimised native CPU execution.
-- Inference MUST be batched: batch_size inputs are run per compiled
-- inference pass and inputs are truncated to a bounded sequence_length, so
-- (re)indexing many posts is not serialised one document at a time.
-- Current implementation: Bumblebee + EXLA, which is native CPU on Apple
-- Silicon (XLA has no Metal backend); neighbour search is HNSW (hnswlib).
-- Apple GPU acceleration via EMLX/MLX is tracked as a follow-up
-- (SPECGAPS A1-14c).
}
invariant ModelCaching { invariant ModelCaching {
-- Model files (~100 MB) downloaded from Hugging Face Hub on first use -- Model files (~100 MB) downloaded from Hugging Face Hub on first use
-- Cached in app data directory, persists across sessions -- Cached in app data directory, persists across sessions
@@ -220,7 +249,7 @@ invariant ModelCaching {
} }
invariant ProjectIsolation { invariant ProjectIsolation {
-- Each project has its own USearch index file and embedding_keys rows -- Each project has its own HNSW index file and embedding_keys rows
-- On project switch: save current index, load new project's index -- On project switch: save current index, load new project's index
-- Model pipeline shared across projects (not reloaded) -- Model pipeline shared across projects (not reloaded)
} }

View File

@@ -114,7 +114,7 @@ rule ImportMediaSideEffects {
if media.is_image: if media.is_image:
ensures: ThumbnailsGenerated(media) ensures: ThumbnailsGenerated(media)
-- small=150px, medium=400px, large=800px, ai=448x448 -- small=150px, medium=400px, large=800px, ai=448x448
-- Asynchronous, emits thumbnailsGenerated on completion -- Synchronous (awaited), logged on error
ensures: FTSIndexUpdated(media) ensures: FTSIndexUpdated(media)
} }
@@ -299,7 +299,7 @@ rule DeleteMediaTranslationSideEffects {
-- updatePost | rename only* | yes | if Δ | no | no | yes | no -- updatePost | rename only* | yes | if Δ | no | no | yes | no
-- publishPost | .md + trans | yes | yes | no | no | yes | no -- publishPost | .md + trans | yes | yes | no | no | yes | no
-- deletePost | delete .md | del | del | no | Δ media | del | no -- deletePost | delete .md | del | del | no | Δ media | del | no
-- importMedia | copy file | yes | no | async | write | no | no -- importMedia | copy file | yes | no | sync | write | no | no
-- updateMedia | no | yes | no | no | rewrite | no | no -- updateMedia | no | yes | no | no | rewrite | no | no
-- replaceMediaFile | overwrite | no | no | regen | no | no | no -- replaceMediaFile | overwrite | no | no | regen | no | no | no
-- deleteMedia | delete all | del | no | del | del all | no | no -- deleteMedia | delete all | del | no | del | del all | no | no

View File

@@ -25,7 +25,7 @@ surface PostFrontmatterSurface {
frontmatter.title frontmatter.title
frontmatter.slug frontmatter.slug
frontmatter.status frontmatter.status
frontmatter.published_at frontmatter.publishedAt
frontmatter.tags frontmatter.tags
frontmatter.categories frontmatter.categories
} }
@@ -35,11 +35,11 @@ surface MediaSidecarSurface {
exposes: exposes:
sidecar.id sidecar.id
sidecar.original_name sidecar.originalName
sidecar.mime_type sidecar.mimeType
sidecar.width sidecar.width
sidecar.height sidecar.height
sidecar.updated_at sidecar.updatedAt
} }
surface TemplateFrontmatterSurface { surface TemplateFrontmatterSurface {
@@ -70,8 +70,8 @@ surface MenuOpmlSurface {
exposes: exposes:
document.header.title document.header.title
document.header.date_created document.header.dateCreated
document.header.date_modified document.header.dateModified
for item in document.body: for item in document.body:
item.kind item.kind
item.label item.label
@@ -88,6 +88,7 @@ config {
value PostFrontmatter { value PostFrontmatter {
-- File path: posts/{YYYY}/{MM}/{slug}.md -- File path: posts/{YYYY}/{MM}/{slug}.md
-- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4 id: String -- UUID v4
title: String title: String
slug: String slug: String
@@ -95,25 +96,29 @@ value PostFrontmatter {
status: draft | published | archived status: draft | published | archived
author: String? -- Only written if present author: String? -- Only written if present
language: String? -- Only written if present (ISO 639-1) language: String? -- Only written if present (ISO 639-1)
do_not_translate: Boolean -- Only written when true doNotTranslate: Boolean -- Only written when true
template_slug: String? -- Only written if present templateSlug: String? -- Only written if present
created_at: Timestamp -- Unix timestamp in milliseconds createdAt: Timestamp -- Unix timestamp in milliseconds
updated_at: Timestamp -- Unix timestamp in milliseconds updatedAt: Timestamp -- Unix timestamp in milliseconds
published_at: Timestamp? -- Only written if published publishedAt: Timestamp? -- Only written if published
tags: List<String> -- Always written, even if empty tags: List<String> -- Always written, even if empty
categories: List<String> -- Always written, even if empty categories: List<String> -- Always written, even if empty
} }
value TranslationFrontmatter { value TranslationFrontmatter {
-- File path: posts/{YYYY}/{MM}/{slug}.{language}.md -- File path: posts/{YYYY}/{MM}/{slug}.{language}.md
-- Translation files only store language-specific metadata. -- Translation files carry their own publication state and timestamps
-- Shared publication state and timestamps are inherited from the -- so that each translation can be rebuilt independently.
-- canonical post file and are not duplicated here. -- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4 id: String -- UUID v4
translation_for: String -- Canonical post UUID translationFor: String -- Canonical post UUID
language: String -- ISO 639-1 language code language: String -- ISO 639-1 language code
title: String -- Translated title title: String -- Translated title
excerpt: String? -- Only written when the translated excerpt differs excerpt: String? -- Only written when the translated excerpt differs
status: draft | published
createdAt: Timestamp -- Unix timestamp in milliseconds
updatedAt: Timestamp -- Unix timestamp in milliseconds
publishedAt: Timestamp -- Canonical post's publishedAt at time of publish
} }
surface TranslationFrontmatterSurface { surface TranslationFrontmatterSurface {
@@ -121,10 +126,14 @@ surface TranslationFrontmatterSurface {
exposes: exposes:
frontmatter.id frontmatter.id
frontmatter.translation_for frontmatter.translationFor
frontmatter.language frontmatter.language
frontmatter.title frontmatter.title
frontmatter.excerpt when frontmatter.excerpt != null frontmatter.excerpt when frontmatter.excerpt != null
frontmatter.status
frontmatter.createdAt
frontmatter.updatedAt
frontmatter.publishedAt
} }
invariant PostFileLayout { invariant PostFileLayout {
@@ -147,9 +156,10 @@ invariant PostTranslationFileLayout {
lang: t.language) lang: t.language)
} }
invariant TranslationFilesInheritCanonicalMetadata { invariant TranslationFrontmatterRoundtrip {
-- Missing status and timestamp fields in translation files are expected. -- Translation files carry status and timestamps explicitly.
-- Rebuild and metadata diff must resolve those values from the canonical post. -- On rebuild, these fields are read back directly; fallback to canonical
-- post values applies only when fields are absent (legacy files).
for t in PostTranslations where file_path != "": for t in PostTranslations where file_path != "":
parse_frontmatter(read_file(t.file_path)) = translation_frontmatter_fields(t) parse_frontmatter(read_file(t.file_path)) = translation_frontmatter_fields(t)
} }
@@ -171,11 +181,12 @@ rule WritePostFile {
value MediaSidecar { value MediaSidecar {
-- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta) -- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta)
-- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext} -- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext}
-- Format: YAML-like key-value (hand-built, not gray-matter frontmatter) -- Format: YAML-like key-value wrapped in --- delimiters (gray-matter style, hand-built serializer)
-- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path -- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path
-- All keys serialized as camelCase
id: String -- UUID v4 id: String -- UUID v4
original_name: String -- Original uploaded filename originalName: String -- Original uploaded filename
mime_type: String mimeType: String
size: Integer -- Bytes size: Integer -- Bytes
width: Integer? width: Integer?
height: Integer? height: Integer?
@@ -185,8 +196,9 @@ value MediaSidecar {
author: String? -- Only written if present author: String? -- Only written if present
language: String? -- Only written if present language: String? -- Only written if present
tags: List<String> -- Always written, even if empty tags: List<String> -- Always written, even if empty
created_at: Timestamp linkedPostIds: List<String> -- UUIDs of posts that reference this media
updated_at: Timestamp createdAt: Timestamp
updatedAt: Timestamp
} }
invariant MediaSidecarLayout { invariant MediaSidecarLayout {
@@ -200,14 +212,16 @@ invariant MediaSidecarLayout {
value TemplateFrontmatter { value TemplateFrontmatter {
-- File path: templates/{slug}.liquid -- File path: templates/{slug}.liquid
-- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4 id: String -- UUID v4
projectId: String -- Scoped to project
slug: String slug: String
title: String title: String
kind: post | list | not_found | partial kind: post | list | not_found | partial
enabled: Boolean enabled: Boolean
version: Integer version: Integer
created_at: Timestamp createdAt: Timestamp
updated_at: Timestamp updatedAt: Timestamp
} }
rule WriteTemplateFile { rule WriteTemplateFile {
@@ -227,15 +241,17 @@ rule WriteTemplateFile {
value ScriptFrontmatter { value ScriptFrontmatter {
-- File path: scripts/{slug}.{extension} -- File path: scripts/{slug}.{extension}
-- YAML frontmatter delimited by --- markers -- YAML frontmatter delimited by --- markers
-- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4 id: String -- UUID v4
projectId: String -- Scoped to project
slug: String slug: String
title: String title: String
kind: macro | utility | transform kind: macro | utility | transform
entrypoint: String -- Default: "render" for macros, "main" otherwise entrypoint: String -- Default: "render" for macros, "main" otherwise
enabled: Boolean enabled: Boolean
version: Integer version: Integer
created_at: Timestamp createdAt: Timestamp
updated_at: Timestamp updatedAt: Timestamp
} }
rule WriteScriptFile { rule WriteScriptFile {
@@ -252,24 +268,20 @@ rule WriteScriptFile {
-- TAGS FILE FORMAT -- TAGS FILE FORMAT
-- ============================================================================ -- ============================================================================
value TagsFile {
-- File path: meta/tags.json
-- Portable JSON format (no internal IDs)
tags: List<TagEntry>
}
value TagEntry { value TagEntry {
-- File path: meta/tags.json
-- Stored as a bare JSON array (no wrapper object)
-- Portable JSON format (no internal IDs), camelCase keys
name: String name: String
color: String? color: String?
post_template_slug: String? postTemplateSlug: String?
} }
invariant TagsFileFormat { invariant TagsFileFormat {
-- Tags are stored as a sorted JSON array -- Tags are stored as a bare sorted JSON array
-- Sorted alphabetically by name (case-insensitive) -- Sorted alphabetically by name (case-insensitive)
parse_json(read_file("meta/tags.json")) = { parse_json(read_file("meta/tags.json")) =
tags: sort_by(Tags, t => lowercase(t.name)) sort_by(tags, t => lowercase(t.name))
}
} }
-- ============================================================================ -- ============================================================================
@@ -278,16 +290,17 @@ invariant TagsFileFormat {
value ProjectJson { value ProjectJson {
-- File path: meta/project.json -- File path: meta/project.json
-- All keys serialized as camelCase
name: String name: String
description: String? description: String?
public_url: String? publicUrl: String?
main_language: String? mainLanguage: String?
default_author: String? defaultAuthor: String?
max_posts_per_page: Integer maxPostsPerPage: Integer
blogmark_category: String? blogmarkCategory: String?
pico_theme: String? picoTheme: String?
semantic_similarity_enabled: Boolean semanticSimilarityEnabled: Boolean
blog_languages: List<String> blogLanguages: List<String>
} }
value CategoriesJson { value CategoriesJson {
@@ -303,18 +316,19 @@ value CategoryMetaJson {
} }
value CategorySettings { value CategorySettings {
render_in_lists: Boolean renderInLists: Boolean
show_title: Boolean showTitle: Boolean
post_template_slug: String? postTemplateSlug: String?
list_template_slug: String? listTemplateSlug: String?
} }
value PublishingJson { value PublishingJson {
-- File path: meta/publishing.json -- File path: meta/publishing.json
ssh_host: String? -- All keys serialized as camelCase
ssh_user: String? sshHost: String?
ssh_remote_path: String? sshUser: String?
ssh_mode: scp | rsync sshRemotePath: String?
sshMode: scp | rsync
} }
invariant MetadataFileLayout { invariant MetadataFileLayout {
@@ -325,7 +339,7 @@ invariant MetadataFileLayout {
meta/category-meta.json = serialize(CategoryMetaJson) meta/category-meta.json = serialize(CategoryMetaJson)
meta/publishing.json = serialize(PublishingJson) meta/publishing.json = serialize(PublishingJson)
meta/menu.opml = serialize(Menu) meta/menu.opml = serialize(Menu)
meta/tags.json = serialize(TagsFile) meta/tags.json = serialize(List<TagEntry>)
} }
-- ============================================================================ -- ============================================================================
@@ -341,8 +355,8 @@ value MenuOpml {
value OpmlHeader { value OpmlHeader {
title: String title: String
date_created: Timestamp dateCreated: Timestamp
date_modified: Timestamp dateModified: Timestamp
} }
value MenuItem { value MenuItem {
@@ -376,6 +390,11 @@ invariant YamlFormatting {
-- Boolean values are lowercase: true/false -- Boolean values are lowercase: true/false
} }
invariant CamelCaseKeys {
-- All serialized keys in YAML frontmatter and JSON metadata use camelCase.
-- Entity/DB fields use snake_case internally; the mapping happens at serialization.
}
invariant AtomicWrites { invariant AtomicWrites {
-- All file writes are atomic -- All file writes are atomic
-- Write to temp file first, then rename -- Write to temp file first, then rename
@@ -390,7 +409,7 @@ invariant RequiredPostFields {
-- These fields are ALWAYS written for posts -- These fields are ALWAYS written for posts
for p in Posts: for p in Posts:
required_fields(p) = { required_fields(p) = {
id, title, slug, status, created_at, updated_at, id, title, slug, status, createdAt, updatedAt,
tags, categories tags, categories
} }
} }
@@ -399,9 +418,9 @@ invariant ConditionalPostFields {
-- These fields are ONLY written if truthy -- These fields are ONLY written if truthy
for p in Posts: for p in Posts:
conditional_fields(p) = { conditional_fields(p) = {
excerpt, author, language, template_slug, published_at excerpt, author, language, templateSlug, publishedAt
} }
-- do_not_translate is only written when true -- doNotTranslate is only written when true
} }
invariant RequiredMediaFields { invariant RequiredMediaFields {
@@ -409,8 +428,8 @@ invariant RequiredMediaFields {
-- Note: 'filename' is NOT a sidecar field — it is the binary path itself -- Note: 'filename' is NOT a sidecar field — it is the binary path itself
for m in Media: for m in Media:
required_fields(m) = { required_fields(m) = {
id, original_name, mime_type, size, id, originalName, mimeType, size,
created_at, updated_at, tags createdAt, updatedAt, tags
} }
} }

View File

@@ -63,6 +63,23 @@ surface GenerationStatusSurface {
generation.generated_files.count generation.generated_files.count
} }
invariant GenerationPublishedOnly {
-- Generation renders the *published* state of the blog, never draft content.
--
-- Post universe: posts that have a published .md file on disk.
-- This includes status=published posts and status=draft posts that were
-- previously published (they still have a file_path with last-published content).
-- Posts that have never been published (no file_path) are excluded entirely.
--
-- Content source: always the .md file on disk (the last-published snapshot).
-- The DB content field (which holds draft edits) is never read during generation.
-- Snapshots set content=nil to ensure file-based resolution.
--
-- Contrast with preview (see preview.allium PreviewDraftOverlay):
-- Preview includes all drafts and prefers DB content over file content,
-- giving the author a live view of unpublished edits.
}
invariant IncrementalByContentHash { invariant IncrementalByContentHash {
-- Files are only written when content_hash changes -- Files are only written when content_hash changes
-- generatedFileHashes table tracks (projectId, relativePath, contentHash) -- generatedFileHashes table tracks (projectId, relativePath, contentHash)
@@ -143,19 +160,39 @@ rule GenerateTagPages {
when: GenerateSiteRequested(generation) when: GenerateSiteRequested(generation)
requires: tag in generation.sections requires: tag in generation.sections
for t in Tags where post_count > 0: for t in Tags where post_count > 0:
ensures: FileGenerated(format("tag/{slug}/index.html", slug: slugify(t.name))) let slug = slugify(t.name)
let page_count = ceil(posts_with_tag(t).count / generation.max_posts_per_page)
ensures: FileGenerated(format("tag/{slug}/index.html", slug: slug))
for page in page_range(2, page_count):
ensures: FileGenerated(format("tag/{slug}/page/{page}/index.html",
slug: slug, page: page))
} }
-- Date section: year and month archives -- Date section: year, month, and day archives
rule GenerateDateArchivePages { rule GenerateDateArchivePages {
when: GenerateSiteRequested(generation) when: GenerateSiteRequested(generation)
requires: date in generation.sections requires: date in generation.sections
for year in distinct_years(Posts): for year in distinct_years(Posts):
let yp = ceil(posts_in_year(year).count / generation.max_posts_per_page)
ensures: FileGenerated(format("{year}/index.html", year: year)) ensures: FileGenerated(format("{year}/index.html", year: year))
for page in page_range(2, yp):
ensures: FileGenerated(format("{year}/page/{page}/index.html",
year: year, page: page))
for month in distinct_months(Posts, year): for month in distinct_months(Posts, year):
let mp = ceil(posts_in_month(year, month).count / generation.max_posts_per_page)
ensures: FileGenerated(format("{year}/{month}/index.html", ensures: FileGenerated(format("{year}/{month}/index.html",
year: year, month: month)) year: year, month: month))
for page in page_range(2, mp):
ensures: FileGenerated(format("{year}/{month}/page/{page}/index.html",
year: year, month: month, page: page))
for day in distinct_days(Posts, year, month):
let dp = ceil(posts_in_day(year, month, day).count / generation.max_posts_per_page)
ensures: FileGenerated(format("{year}/{month}/{day}/index.html",
year: year, month: month, day: day))
for page in page_range(2, dp):
ensures: FileGenerated(format("{year}/{month}/{day}/page/{page}/index.html",
year: year, month: month, day: day, page: page))
} }
-- Template rendering context -- Template rendering context

View File

@@ -203,3 +203,27 @@ invariant SidecarRoundtrip {
parse_sidecar(m.sidecar_path).caption = m.caption parse_sidecar(m.sidecar_path).caption = m.caption
parse_sidecar(m.sidecar_path).tags = m.tags parse_sidecar(m.sidecar_path).tags = m.tags
} }
rule BatchImportProcessLinkImages {
when: BatchImportImagesRequested(project, post, file_paths, language)
requires: not OfflineMode
for source_path in file_paths where is_image(source_path):
let media = ImportMedia(source_path, project)
let analysis = AnalyzeImage(media, language)
ensures: MediaUpdated(media, analysis)
ensures: PostMediaLinked(media, post)
@guidance
-- Triggered from post editor quick action "Add Gallery Images".
-- AI results auto-applied without user confirmation.
-- After metadata is set, media is auto-translated to all configured blog languages.
-- Non-image files skipped entirely.
-- Concurrency limit from project metadata image_import_concurrency (default 4, min 1, max 8).
-- Toast per completed image + final summary toast.
-- On completion: [[gallery]] macro inserted into post content and post editor refreshed.
}
config {
batch_image_import_concurrency_default: Integer = 4
batch_image_import_concurrency_min: Integer = 1
batch_image_import_concurrency_max: Integer = 8
}

View File

@@ -1,28 +1,30 @@
-- allium: 1 -- allium: 1
-- bDS Navigation Menu -- bDS Navigation Menu
-- Scope: core (read for rendering), extension Bucket F (menu editor UI) -- Scope: core (read for rendering), extension Bucket F (menu editor UI)
-- Distilled from: src/main/engine/MenuEngine.ts -- File-only model: no DB table. Loaded from meta/menu.opml into a
-- transient value, mutated in memory, written back to OPML on save.
surface MenuManagementSurface { surface MenuManagementSurface {
facing _: MenuOperator facing _: MenuOperator
provides: provides:
UpdateMenuRequested(menu, items) MenuLoadRequested(project_id)
UpdateMenuRequested(items)
SyncMenuFromFilesystemRequested(project_id)
} }
value MenuItem { value MenuItem {
kind: page | submenu | category_archive | home kind: page | submenu | category_archive | home
label: String label: String
slug: String? slug: String? -- pageSlug for page/home, categoryName for category_archive
children: List<MenuItem>? -- only for submenu kind children: List<MenuItem>? -- present only for submenu kind
} }
entity Menu { value Menu {
items: List<MenuItem> items: List<MenuItem>
-- Derived -- Derived
home_items: items where kind = home home_entry: items.first -- always home after normalization
home_entry: home_items.first
} }
surface MenuSurface { surface MenuSurface {
@@ -30,27 +32,42 @@ surface MenuSurface {
exposes: exposes:
menu.items.count menu.items.count
menu.home_items.count
menu.home_entry.label menu.home_entry.label
} }
invariant HomeAlwaysPresent { invariant HomeAlwaysFirst {
-- The menu always has a Home entry, extracted and prepended -- Normalization guarantees home is always the first item.
-- UpdateMenu strips any home entries from input, then prepends one.
for menu in Menus: for menu in Menus:
menu.items.first.kind = home menu.items.first.kind = home
} }
invariant MenuPersistedAsOpml { invariant MenuPersistedAsOpml {
-- meta/menu.opml is the canonical storage format -- meta/menu.opml is the sole persistent store (no DB table).
-- Uses OPML with outline elements for each item -- OPML outline attributes: text (label), type (kind),
-- pageSlug (slug for page/home), categoryName (slug for category_archive).
-- Nested <outline> elements represent submenu children.
parse_opml(read_file("meta/menu.opml")) = menu.items parse_opml(read_file("meta/menu.opml")) = menu.items
} }
rule UpdateMenu { rule LoadMenu {
when: UpdateMenuRequested(menu, items) when: MenuLoadRequested(project_id)
-- Normalizes Home entry: extracts from items, prepends -- Reads meta/menu.opml; if file missing, returns default (home-only) menu.
let without_home = items where kind != home -- Normalizes: strips home entries from body, prepends canonical home.
let home = MenuItem{kind: home, label: "Home"} ensures: MenuLoaded(project_id, normalize(parse_opml_or_empty(project_id)))
ensures: menu.items = build_menu_items(home, without_home) }
ensures: MenuFileWritten(menu)
rule UpdateMenu {
when: UpdateMenuRequested(items)
-- Normalizes Home entry: strips all home items, prepends canonical home.
-- Writes normalized menu back to meta/menu.opml.
let without_home = items where kind != home
ensures: MenuFileWritten(normalize(without_home))
}
rule SyncMenuFromFilesystem {
when: SyncMenuFromFilesystemRequested(project_id)
-- Reloads menu from OPML, normalizes, writes back (round-trip repair).
ensures: MenuLoaded(project_id, _)
ensures: MenuFileWritten(_)
} }

View File

@@ -18,7 +18,7 @@ value Slug {
-- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens -- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens
-- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used. -- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used.
-- Verify transliteration matches the established bDS behaviour for this set. -- Verify transliteration matches the established bDS behaviour for this set.
-- Uniqueness: tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp} -- Uniqueness: tries base, then {slug}-2, {slug}-3, … (unbounded numeric suffix)
} }
value PostFilePath { value PostFilePath {

View File

@@ -49,18 +49,24 @@ rule StopPreview {
} }
-- Route resolution -- Route resolution
-- Preview renders all posts (published + draft) on-demand via Liquid templates.
-- Content priority: DB content (draft edits) over published .md file content.
-- See invariant PreviewDraftOverlay below.
rule ServePostPreview { rule ServePostPreview {
when: PreviewRequest(path) when: PreviewRequest(path)
requires: is_post_path(path) requires: is_post_path(path)
-- path matches "/{yyyy}/{mm}/{dd}/{slug}" -- path matches "/{yyyy}/{mm}/{dd}/{slug}"
-- Renders post via Liquid template with full PageRenderer context -- Finds post by slug+date regardless of status (published or draft).
-- Content resolved via editor_body: DB content if present, else .md file.
-- Renders via Liquid template with full PageRenderer context.
ensures: PreviewResponse(rendered_html) ensures: PreviewResponse(rendered_html)
} }
rule ServeDraftPreview { rule ServeDraftPreview {
when: PreviewDraftRequest(path, post_id) when: PreviewDraftRequest(path, post_id)
-- Renders draft content (from DB, not filesystem) -- Explicit draft preview by post_id (used by editor preview pane).
-- Renders draft content (from DB, not filesystem).
ensures: PreviewResponse(rendered_html) ensures: PreviewResponse(rendered_html)
} }
@@ -68,6 +74,7 @@ rule ServeArchivePreview {
when: PreviewRequest(path) when: PreviewRequest(path)
requires: is_archive_path(path) requires: is_archive_path(path)
-- Category, tag, date archives with pagination -- Category, tag, date archives with pagination
-- Includes both published and draft posts in listings.
ensures: PreviewResponse(rendered_html) ensures: PreviewResponse(rendered_html)
} }
@@ -92,6 +99,28 @@ rule ServeLanguagePrefixedRoute {
ensures: PreviewResponse(translated_html) ensures: PreviewResponse(translated_html)
} }
invariant PreviewDraftOverlay {
-- Preview is the draft workspace: it shows what the blog *will* look like,
-- not what it currently looks like on the published site.
--
-- Post universe: all posts with status in {published, draft}.
-- Archived posts are excluded.
--
-- Content priority (per post):
-- 1. DB content field (draft edits not yet published) → used when non-nil
-- 2. Published .md file (last-published snapshot) → used when DB content is nil
-- 3. Empty string → fallback if neither exists
--
-- This means:
-- - A purely draft post (never published) renders from DB content.
-- - A published-then-edited post renders from DB content (the draft edits).
-- - A published post with no pending edits renders from its .md file.
--
-- Contrast with generation (see generation.allium GenerationPublishedOnly):
-- Generation uses *only* published .md file content, never DB draft content,
-- and excludes posts that have never been published.
}
invariant ThemeSwitching { invariant ThemeSwitching {
-- Preview supports live theme/mode switching via query params -- Preview supports live theme/mode switching via query params
-- ?theme=amber&mode=dark etc. -- ?theme=amber&mode=dark etc.

View File

@@ -279,7 +279,6 @@ entity AiModel {
max_output_tokens: Integer max_output_tokens: Integer
interleaved: String? -- interleaved capability descriptor interleaved: String? -- interleaved capability descriptor
status: String? -- active | deprecated | preview status: String? -- active | deprecated | preview
provider_package_ref: String? -- provider-specific legacy package reference
updated_at: Timestamp updated_at: Timestamp
} }
@@ -288,7 +287,7 @@ entity AiModelModality {
provider: AiProvider provider: AiProvider
model_id: String model_id: String
direction: String -- "input" | "output" direction: String -- "input" | "output"
modality: String -- "text" | "image" | "audio" | "video" modality: String -- "text" | "image" | "audio" | "file" | "tool"
} }
entity AiCatalogMeta { entity AiCatalogMeta {
@@ -552,7 +551,6 @@ surface AiModelRecordSurface {
model.max_output_tokens model.max_output_tokens
model.interleaved when model.interleaved != null model.interleaved when model.interleaved != null
model.status when model.status != null model.status when model.status != null
model.provider_package_ref when model.provider_package_ref != null
model.updated_at model.updated_at
} }

View File

@@ -20,6 +20,7 @@ enum ScriptStatus {
} }
entity Script { entity Script {
project_id: String
slug: String slug: String
title: String title: String
kind: macro | utility | transform kind: macro | utility | transform
@@ -63,8 +64,8 @@ surface ScriptManagementSurface {
facing _: ScriptOperator facing _: ScriptOperator
provides: provides:
CreateScriptRequested(title, kind, content, entrypoint) CreateScriptRequested(project, title, kind, content, entrypoint)
CreateAndPublishScriptRequested(title, kind, content, entrypoint) CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
UpdateScriptRequested(script, changes) UpdateScriptRequested(script, changes)
PublishScriptRequested(script) PublishScriptRequested(script)
DeleteScriptRequested(script) DeleteScriptRequested(script)
@@ -113,9 +114,10 @@ surface ScriptRuntimeSurface {
} }
invariant UniqueScriptSlug { invariant UniqueScriptSlug {
-- Slug uniqueness is scoped per project, not globally.
for a in Scripts: for a in Scripts:
for b in Scripts: for b in Scripts:
a != b implies a.slug != b.slug a != b and a.project_id = b.project_id implies a.slug != b.slug
} }
invariant ScriptFileLayout { invariant ScriptFileLayout {
@@ -125,11 +127,12 @@ invariant ScriptFileLayout {
-- Script files use standard --- YAML frontmatter -- Script files use standard --- YAML frontmatter
rule CreateScript { rule CreateScript {
when: CreateScriptRequested(title, kind, content, entrypoint) when: CreateScriptRequested(project, title, kind, content, entrypoint)
let slug = slugify(title) let slug = slugify(title)
-- Creates a draft script: content stored in DB, no file written yet -- Creates a draft script: content stored in DB, no file written yet
ensures: ensures:
let new_script = Script.created( let new_script = Script.created(
project_id: project.id,
slug: slug, slug: slug,
title: title, title: title,
kind: kind, kind: kind,
@@ -160,11 +163,12 @@ rule ReopenPublishedScript {
rule CreateAndPublishScript { rule CreateAndPublishScript {
-- Alternative creation path: create + immediately publish (file written) -- Alternative creation path: create + immediately publish (file written)
-- Some implementations may expose this as a single user action -- Some implementations may expose this as a single user action
when: CreateAndPublishScriptRequested(title, kind, content, entrypoint) when: CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
let slug = slugify(title) let slug = slugify(title)
requires: ValidateScript(content) = valid requires: ValidateScript(content) = valid
ensures: ensures:
let new_script = Script.created( let new_script = Script.created(
project_id: project.id,
slug: slug, slug: slug,
title: title, title: title,
kind: kind, kind: kind,
@@ -234,7 +238,7 @@ rule ExecuteTransform {
-- Execution uses the same managed job host API contract as other batch -- Execution uses the same managed job host API contract as other batch
-- scripts and may report progress while mass-processing remote or local -- scripts and may report progress while mass-processing remote or local
-- content. -- content.
let transforms = Scripts where kind = transform and enabled = true let transforms = Scripts where project_id = data.project_id and kind = transform and enabled = true
for t in ordered_by(transforms, s => s.updated_at, s => s.slug, s => s.id): for t in ordered_by(transforms, s => s.updated_at, s => s.slug, s => s.id):
requires: t.entrypoint != "" requires: t.entrypoint != ""
ensures: TransformApplied(t, data) ensures: TransformApplied(t, data)

View File

@@ -12,7 +12,7 @@ surface SearchControlSurface {
provides: provides:
SearchPostsRequested(query, filters) SearchPostsRequested(query, filters)
SearchMediaRequested(query) SearchMediaRequested(query, filters)
} }
surface SearchIndexRuntimeSurface { surface SearchIndexRuntimeSurface {
@@ -24,8 +24,9 @@ surface SearchIndexRuntimeSurface {
} }
value StemmerLanguage { value StemmerLanguage {
-- Snowball stemmers for 24 languages -- Snowball stemmers via library (Stemex)
-- ISO 639-1 to Snowball mapping -- Languages with a Snowball algorithm get real stemming;
-- others pass through unstemmed
-- Applied to both indexing and query processing -- Applied to both indexing and query processing
code: String code: String
} }
@@ -38,19 +39,29 @@ surface StemmerLanguageSurface {
} }
entity PostSearchIndex { entity PostSearchIndex {
-- Full-text index projection -- FTS5 virtual table with per-field stemmed columns
-- Indexed fields: title, excerpt, content, tags, categories -- Each field is stemmed independently; translations are
-- Plus all translation titles, excerpts, and content -- stemmed with their own language stemmer and appended
-- to the corresponding field
post: post/Post post: post/Post
stemmed_content: String title: String
excerpt: String
content: String
tags: String
categories: String
} }
entity MediaSearchIndex { entity MediaSearchIndex {
-- Full-text index projection -- FTS5 virtual table with per-field stemmed columns
-- Indexed fields: title, alt, caption, original_name, tags -- Each field is stemmed independently; translations are
-- Plus all translation titles, alts, and captions -- stemmed with their own language stemmer and appended
-- to the corresponding field
media: media/Media media: media/Media
stemmed_content: String title: String
alt: String
caption: String
original_name: String
tags: String
} }
invariant CrossLanguageStemming { invariant CrossLanguageStemming {
@@ -77,42 +88,80 @@ rule SearchPosts {
} }
rule SearchMedia { rule SearchMedia {
when: SearchMediaRequested(query) when: SearchMediaRequested(query, filters)
-- Full-text search with optional filters:
-- language, tags, year, month, date range (from/to)
-- Returns paginated results with total count
let stemmed_query = stem(query, detect_language(query)) let stemmed_query = stem(query, detect_language(query))
let matched = search_fts(MediaSearchIndex, stemmed_query) let matched = search_fts(MediaSearchIndex, stemmed_query, filters)
ensures: SearchResults( ensures: SearchResults(
media: matched media: matched,
total: matched.count,
offset: filters.offset,
limit: filters.limit
) )
} }
rule IndexPost { rule IndexPost {
when: SearchIndexUpdated(post) when: SearchIndexUpdated(post)
-- Stems: title + excerpt + content + tags + categories -- Delete-and-reinsert: no in-place update for FTS5 rows
-- Plus all translations' title + excerpt + content -- Each field is stemmed per-language; translations are stemmed
let all_text = concat_post_text(post) -- with their own language stemmer and joined into the same field
-- Concatenates: post.title, post.excerpt, post.content, let lang = post.language
-- join(post.tags, " "), join(post.categories, " "), let translations = post.translations
-- and all translations' title, excerpt, content let title = join_stemmed(
let index_entry = PostSearchIndex{post: post} stem(post.title, lang),
ensures: for t in translations: stem(t.title, t.language)
if exists index_entry: )
index_entry.stemmed_content = stem(all_text) let excerpt = join_stemmed(
else: stem(post.excerpt, lang),
PostSearchIndex.created(post: post, stemmed_content: stem(all_text)) for t in translations: stem(t.excerpt, t.language)
)
let content = join_stemmed(
stem(post.content, lang),
for t in translations: stem(t.content, t.language)
)
let tags = stem(join(post.tags, " "), lang)
let categories = stem(join(post.categories, " "), lang)
ensures: not exists PostSearchIndex{post: post}
ensures: PostSearchIndex.created(
post: post,
title: title,
excerpt: excerpt,
content: content,
tags: tags,
categories: categories
)
} }
rule IndexMedia { rule IndexMedia {
when: SearchIndexUpdated(media) when: SearchIndexUpdated(media)
-- Stems: title + alt + caption + original_name + tags -- Delete-and-reinsert: no in-place update for FTS5 rows
-- Plus all translations' title, alt, caption -- Each field is stemmed per-language; translations are stemmed
let all_text = concat_media_text(media) -- with their own language stemmer and joined into the same field
-- Concatenates: media.title, media.alt, media.caption, let lang = media.language
-- media.original_name, join(media.tags, " "), let translations = media.translations
-- and all translations' title, alt, caption let title = join_stemmed(
let index_entry = MediaSearchIndex{media: media} stem(media.title, lang),
ensures: for t in translations: stem(t.title, t.language)
if exists index_entry: )
index_entry.stemmed_content = stem(all_text) let alt = join_stemmed(
else: stem(media.alt, lang),
MediaSearchIndex.created(media: media, stemmed_content: stem(all_text)) for t in translations: stem(t.alt, t.language)
)
let caption = join_stemmed(
stem(media.caption, lang),
for t in translations: stem(t.caption, t.language)
)
let original_name = stem(media.original_name, lang)
let tags = stem(join(media.tags, " "), lang)
ensures: not exists MediaSearchIndex{media: media}
ensures: MediaSearchIndex.created(
media: media,
title: title,
alt: alt,
caption: caption,
original_name: original_name,
tags: tags
)
} }

View File

@@ -652,6 +652,9 @@ rule ImportListClick {
-- Full git interface, not the SidebarEntityList pattern. -- Full git interface, not the SidebarEntityList pattern.
-- Three possible states: loading, not_a_repo, active_repo. -- Three possible states: loading, not_a_repo, active_repo.
-- Backend: BDS.Git provides status, diff, commit_all, history,
-- fetch, pull, push, prune_lfs_cache, remote_state, initialize_repo.
-- The sidebar must surface these capabilities directly.
-- State: not_a_repo -- State: not_a_repo
-- Remote URL text input + "Initialize Git" button. -- Remote URL text input + "Initialize Git" button.

View File

@@ -10,6 +10,7 @@ enum TemplateStatus {
} }
entity Template { entity Template {
project_id: String
slug: String slug: String
title: String title: String
kind: post | list | not_found | partial kind: post | list | not_found | partial
@@ -23,8 +24,8 @@ entity Template {
-- Derived -- Derived
content_location: if status = published: file_path else: content content_location: if status = published: file_path else: content
referencing_posts: Posts where template_slug = this.slug referencing_posts: Posts where template_slug = this.slug and project_id = this.project_id
referencing_tags: Tags where post_template_slug = this.slug referencing_tags: Tags where post_template_slug = this.slug and project_id = this.project_id
transitions status { transitions status {
draft -> published draft -> published
@@ -36,8 +37,8 @@ surface TemplateManagementSurface {
facing _: TemplateOperator facing _: TemplateOperator
provides: provides:
CreateTemplateRequested(title, kind, content) CreateTemplateRequested(project, title, kind, content)
CreateAndPublishTemplateRequested(title, kind, content) CreateAndPublishTemplateRequested(project, title, kind, content)
UpdateTemplateRequested(template, changes) UpdateTemplateRequested(template, changes)
PublishTemplateRequested(template) PublishTemplateRequested(template)
DeleteTemplateRequested(template) DeleteTemplateRequested(template)
@@ -45,9 +46,10 @@ surface TemplateManagementSurface {
} }
invariant UniqueTemplateSlug { invariant UniqueTemplateSlug {
-- Slug uniqueness is scoped per project, not globally.
for a in Templates: for a in Templates:
for b in Templates: for b in Templates:
a != b implies a.slug != b.slug a != b and a.project_id = b.project_id implies a.slug != b.slug
} }
invariant TemplateFrontmatter { invariant TemplateFrontmatter {
@@ -85,11 +87,12 @@ invariant RebuildTemplatesIndexesOnlyProjectTemplates {
} }
rule CreateTemplate { rule CreateTemplate {
when: CreateTemplateRequested(title, kind, content) when: CreateTemplateRequested(project, title, kind, content)
let slug = slugify(title) let slug = slugify(title)
-- Creates a draft template: content stored in DB, no file written yet -- Creates a draft template: content stored in DB, no file written yet
ensures: ensures:
let new_template = Template.created( let new_template = Template.created(
project_id: project.id,
slug: slug, slug: slug,
title: title, title: title,
kind: kind, kind: kind,
@@ -105,11 +108,12 @@ rule CreateTemplate {
rule CreateAndPublishTemplate { rule CreateAndPublishTemplate {
-- Alternative creation path: create + immediately publish (file written) -- Alternative creation path: create + immediately publish (file written)
-- Some implementations may expose this as a single user action -- Some implementations may expose this as a single user action
when: CreateAndPublishTemplateRequested(title, kind, content) when: CreateAndPublishTemplateRequested(project, title, kind, content)
let slug = slugify(title) let slug = slugify(title)
requires: ValidateLiquid(content) = valid requires: ValidateLiquid(content) = valid
ensures: ensures:
let new_template = Template.created( let new_template = Template.created(
project_id: project.id,
slug: slug, slug: slug,
title: title, title: title,
kind: kind, kind: kind,

Some files were not shown because too many files have changed in this diff Show More