Compare commits

..

110 Commits

Author SHA1 Message Date
Hermes Agent
bf9340352c chore: added hint for xvfb-run mix test 2026-06-01 16:43:26 +00:00
db98944d10 fix: added css rules for git sidebar 2026-05-31 17:43:12 +02:00
5e99cb7a09 fix: fixed behaviour of bundled app for decrypt and ai chat 2026-05-31 17:26:50 +02:00
040b5db37b fix: a2ui is much closer to bDS parity than before 2026-05-31 14:32:15 +02:00
a33131ddea A2UI parity: restore chart-type guidance in system prompt, add detailed schemas for form fields, card actions, tab content, and mindmap nodes 2026-05-31 14:05:13 +02:00
ef6f8a54b2 chore: remove done todo 2026-05-30 22:20:42 +02:00
70d2342274 fix: pretty-print json on serialisation to filesystem 2026-05-30 21:58:31 +02:00
c1b7ceae6c fix: proper mac bundling with code signing 2026-05-30 21:47:03 +02:00
1d17b6e884 feat: pipeline to create a full mac app 2026-05-30 21:21:07 +02:00
360a8d971a D4-7: add UI tests for translation validation, find duplicates, git diff, menu toolbar, import analysis 2026-05-30 20:41:06 +02:00
c30757b3b7 D4-6: tag editor UI tests + delete confirmation overlay 2026-05-30 20:30:39 +02:00
f6e1b679f0 Spec gap D4-5: add TemplateEditor LiveComponent tests for save/validate/delete events 2026-05-30 20:20:03 +02:00
63d6c9f215 D4-4: add script editor UI tests for save, run, check syntax, delete 2026-05-30 20:10:13 +02:00
2ba8be2fc6 Resolve D4-3: add WelcomeScreen/CSP/chart-surface tests for editor_chat.allium 2026-05-30 20:02:36 +02:00
4731bc0cd2 D4-2: add 56 UI tests for editor_settings (MCP agents, style/theme, search filter, categories CRUD) 2026-05-30 19:52:23 +02:00
8bc371eb3f Fix D4-1: add standalone delete_media_translation tests and MediaDetectLanguage rule integration test 2026-05-30 19:40:49 +02:00
b65c2be29b D3: close out partial test coverage gaps with new tests + execute_macro degrade-to-empty fix 2026-05-30 19:33:52 +02:00
ee4d0dd33f D2-10/D2-12/D2-15/D2-16: close out remaining D2 spec gaps with tests + validate_media implementation 2026-05-30 19:25:20 +02:00
8c71ece887 D2-9: add max_posts_per_page 1..500 constraint tests 2026-05-30 19:17:09 +02:00
b1438d5222 Fix D2-8: add width/height assertions to ConditionalMediaFields nil-fields-absent test 2026-05-30 19:15:50 +02:00
87f2f22241 fill D2-7: test nil excerpt/author/language absent from frontmatter 2026-05-30 19:14:06 +02:00
60acda3fee fix: add SidecarRoundtrip tests for D2-6 2026-05-30 19:10:40 +02:00
ab6a03dc54 D2-5: add FrontmatterRoundtrip test 2026-05-30 19:08:09 +02:00
0afb017e43 Implement create_and_publish_script/1 and add tests for D2-3/D2-4 spec gaps 2026-05-30 19:02:20 +02:00
cf553e2f78 Add create_and_publish_template/1 (D2-2 spec gap) 2026-05-30 14:51:44 +02:00
cb658aba1a clean up unused alias warning in CSM013 test 2026-05-30 14:47:33 +02:00
544ff65e3b D2-1: add RemoveCategory tests covering state/files/DB cleanup and no-op for non-existent 2026-05-30 14:44:20 +02:00
08eb9e4ea1 B2-1..B2-9: distill minor code behaviors into specs (post/project/template/media_processing/generation/dashboard) 2026-05-30 14:39:11 +02:00
723a7ec1f7 B1-5..B1-20: distill remaining code behaviors into specs (rendering.allium, post/media/task/generation/editor specs) 2026-05-30 14:33:19 +02:00
dfb2f8870b chore: some stupid whitelistings for claude code dumbness 2026-05-30 14:21:55 +02:00
f0919f24a5 B1-4: distill Style as its own :style singleton tab in editor_settings spec 2026-05-30 14:21:28 +02:00
72f2c829ca B1-3: distill Technology, MCP, and Data Maintenance settings sections into editor_settings spec 2026-05-30 14:17:59 +02:00
7c7f629dd2 B1-2: distill auto-translation system into translation.allium spec 2026-05-30 14:14:02 +02:00
dd760d0f2b B1-1: distill 9 inline surface types into editor_chat.allium spec 2026-05-30 13:39:57 +02:00
fb794ae833 Fix C-1: add cache_read_tokens/cache_write_tokens to schema.allium ChatMessage 2026-05-30 13:35:06 +02:00
df0ae6a41b D1-18: HomeItemProtection — Home menu item cannot be moved, reordered, or deleted 2026-05-30 13:33:40 +02:00
e515cfacc6 D1-17: add tests for protected categories deletion rejection (article/aside/page/picture) 2026-05-30 13:25:42 +02:00
7e9cc72e1f fix: D1-16 cancel orphaned debounce timer so index saves coalesce; add tests 2026-05-30 09:48:32 +02:00
257a06e5d1 feat: D1-15 implement drag-and-drop image chain (import+thumbnails+link+insert) with tests 2026-05-30 09:34:41 +02:00
1b37f1fcec test: D1-14 cover ReplaceMediaFileSideEffects file replace + sync thumbnail regen 2026-05-30 09:18:36 +02:00
56caa653bb test: D1-13 cover DiscardPostChangesSideEffects FTS re-sync after discard 2026-05-30 09:15:15 +02:00
925fe97007 test: D1-12 enforce BoundedToolLoop via config.chat_max_tool_rounds 2026-05-30 09:13:03 +02:00
d688c61b0e test: D1-11 cover ChatContextTruncation invariant in chat requests 2026-05-30 09:08:51 +02:00
8db7bcf357 test: D1-10 cover TransformPipelineContinuation with first-transform failure 2026-05-30 09:03:09 +02:00
2bed225133 style: fix pre-existing formatting drift across codebase 2026-05-30 09:00:29 +02:00
7045b10738 fix: A1-17 route bds2://new-post deep links into transform pipeline and draft creation 2026-05-30 08:58:22 +02:00
ebf6136d2f fix: blogmark bookmarklet uses bds2:// scheme to avoid legacy bds:// clash 2026-05-29 22:46:26 +02:00
ae6659bcf3 docs: track A1-17 unimplemented blogmark deep-link handler in SPECGAPS 2026-05-29 22:43:30 +02:00
8bfc509472 fix: D1-9 implement ExecuteTransform pipeline with ordering and toast budget 2026-05-29 22:41:34 +02:00
e89a061d8f test: D1-8 enforce MacroTimeout, macro times out within wall-clock budget 2026-05-29 22:31:55 +02:00
d606d9b26b test: D1-7 enforce LiquidOperatorSubset, reject unsupported comparison operators at publish 2026-05-29 22:25:06 +02:00
a9740207cc fix: D1-6 enforce LiquidFilterSubset, reject unsupported filters at publish 2026-05-29 22:21:47 +02:00
535ab81082 test: D1-5 enforce LiquidTagSubset via restricted parser, reject unsupported tags 2026-05-29 22:13:01 +02:00
0ce90e96e5 test: D1-4 cover UserTemplateDirectoryOverridesBundledDefaults 2026-05-29 22:04:32 +02:00
8cb6d238b9 test: D1-3 cover BundledDefaultTemplatesExistOutsideProjectData with no Template rows 2026-05-29 22:02:19 +02:00
cf8b0af15f fix: A1-16 keep public project content out of repo via per-user content location and machine-local project registry 2026-05-29 21:58:46 +02:00
9d5764b251 fix: added things around project folder pollution from program runs 2026-05-29 21:45:15 +02:00
3a77761f96 fix: D1-2 correct post translation unique_constraint name so duplicate-language violations return a changeset error 2026-05-29 21:15:40 +02:00
aff4b63188 chore: added some command allowances 2026-05-29 21:14:33 +02:00
91b0ffe4c5 fix: D1-1 correct media translation unique_constraint name so duplicate-language violations return a changeset error 2026-05-29 21:13:18 +02:00
84b91750fb fix: A1-14c run embedding model on Apple GPU via EMLX with EXLA-CPU fallback 2026-05-29 16:26:33 +02:00
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
a5ac74db91 fix(style): add missing @impl true to all handle_call clauses in Publishing GenServer (CSM-036) 2026-05-27 19:19:50 +02:00
beca4d992f fix(docs): document UILocale process-dict invariant and add enforcement tests (CSM-035) 2026-05-27 19:16:42 +02:00
9e6d93a4b3 fix(safety): replace File.read! with File.read and error-tuple handling in preview_assets and templates (CSM-034) 2026-05-27 19:10:13 +02:00
e29dfb490a fix(perf): replace Enum.each + individual inserts with preloaded keys and batch upsert in embeddings (CSM-033) 2026-05-27 19:03:21 +02:00
f2b340ba86 fix(style): replace Map.get with dot access and pattern matching where keys are guaranteed (CSM-032) 2026-05-27 18:33:42 +02:00
d18e0ef7f2 fix(rendering): replace inline try/rescue with with-chains and safe_liquex_render helpers (CSM-031) 2026-05-27 18:12:23 +02:00
2d796cee83 updated specgaps for A3-1 2026-05-27 17:56:19 +02:00
b052d59376 fix(fs): handle File.mkdir_p errors and remove bang variants in sidecars and release packaging (CSM-030) 2026-05-11 20:25:06 +02:00
4a089b0856 fix(perf): bind length/1 to variables before loop bodies in route paths and sidebar (CSM-029) 2026-05-11 20:18:56 +02:00
2632649cdc fix(rendering): replace raising read_template_file with try_read in macro templates (CSM-028) 2026-05-11 20:09:49 +02:00
782511d523 fix(templates): replace equality check with pattern matching in rewrite_template_file (CSM-027) 2026-05-11 20:06:15 +02:00
1cb59d7a78 chore: update the spec gaps with decisionm points 2026-05-11 12:19:16 +02:00
9844f3555a chore: analyse specs against code 2026-05-11 11:56:34 +02:00
99dc1c2216 chore: remove redundant export-only tests, add test audit procedure
Deleted chat_editor_test.exs and import_editor_test.exs which only
checked function_exported?/Code.ensure_loaded? without exercising any
behavior — both components are already tested via LiveView rendering
in shell_live_test.exs and import_shell_live_test.exs respectively.

Added TESTAUDIT.md documenting the procedure for periodic test suite
audits to catch non-behavioral tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:35:24 +02:00
71fb99af16 chore: unit tests adaption to idea of gate test 2026-05-11 10:21:00 +02:00
0808b27057 chore: moved from delay-based tests to deterministic gated server tests
for chat
2026-05-11 10:20:46 +02:00
255 changed files with 26555 additions and 7576 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

@@ -1 +1,2 @@
- [Fix all test failures](feedback_fix_all_failures.md) — Never dismiss failures as pre-existing; fix everything - [Fix all test failures](feedback_fix_all_failures.md) — Never dismiss failures as pre-existing or flaky; investigate and fix
- [Debug targeted](feedback_targeted_debugging.md) — Analyze the code and fix; don't brute-force with repeated suite runs

View File

@@ -1,11 +1,13 @@
--- ---
name: Fix all test failures name: Fix all test failures including flaky ones
description: Never dismiss test failures as pre-existing — if tests fail after changes, fix them description: Never dismiss test failures as pre-existing or flaky — investigate root cause and stabilize
type: feedback type: feedback
--- ---
All test failures after changes must be fixed, even if they appear unrelated. The test suite was clean before, so any failure is the responsibility of the current change. All test failures must be fixed, even if they appear unrelated to current changes. The test suite was clean before, so any failure is my responsibility.
**Why:** The user confirmed the suite was green before. Dismissing failures as "pre-existing" is wrong and wastes time. Flaky tests are deeper problems waiting to surface. Running a test in isolation and seeing it pass is never enough — must find out why it was flaky in the full suite run and make it stable.
**How to apply:** After making changes, if any test fails, investigate and fix it before reporting the task as done. Never stash/skip/ignore failures. **Why:** Dismissing failures as "pre-existing" or "flaky" is wrong. Flaky tests indicate real issues (race conditions, test pollution, shared state) that will bite harder later.
**How to apply:** After making changes, if any test fails: investigate the root cause, fix it, and verify it passes reliably in the full suite. Never stash, never skip, never re-run and hope. Never dismiss ordering-dependent failures — find and fix the shared state or race condition.

View File

@@ -0,0 +1,11 @@
---
name: Debug targeted, don't brute-force
description: When investigating flaky tests, analyze the code and fix — don't brute-force with repeated full suite runs
type: feedback
---
If you already know which test is failing and why, fix it. Don't waste time running the full suite 20 times hoping to capture output.
**Why:** It's slow, wasteful, and avoids thinking. Analyze the code, understand the race, fix it.
**How to apply:** When a test fails, read the test, understand what it does, identify the root cause, and fix it. Only re-run to verify the fix, not to gather more data you already have.

View File

@@ -6,7 +6,26 @@
"Bash(mix dialyzer *)", "Bash(mix dialyzer *)",
"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 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 *)",
"Bash(mix deps.get)",
"Bash(allium --help)",
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(echo \"EXIT: $?\")",
"Bash(echo \"exit: $?\")",
"Bash(MIX_ENV=prod mix release bds --overwrite)",
"Bash(MIX_ENV=prod mix bds.bundle.macos)"
] ]
} }
} }

9
.gitignore vendored
View File

@@ -3,10 +3,17 @@
/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 # Project public content (posts, media, templates, generated html) lives in the
# per-user default content folder, never the repo. See PublicContentLivesInProjectFolder.
/priv/data/projects/
# Embeddings index artifacts are per-project runtime caches, never committed.
*.usearch
*.usearch.meta.json
*.eztmp/

View File

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

View File

@@ -162,3 +162,56 @@ Notes for developers:
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details. - [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details.
- [API.md](/Users/gb/Projects/bDS2/API.md) is generated from the live scripting capability map and should stay in sync with runtime changes. - [API.md](/Users/gb/Projects/bDS2/API.md) is generated from the live scripting capability map and should stay in sync with runtime changes.
- When changing persistence or localization behavior, check both the database side and the filesystem/render side before assuming the change is complete. - When changing persistence or localization behavior, check both the database side and the filesystem/render side before assuming the change is complete.
## Packaging The macOS App
`mix bds.bundle.macos` produces a self-contained, ad-hoc-signed `BDS2.app`. It
bundles the `:bds` release (ERTS included) plus the wxWidgets dylibs relocated
via `@loader_path`, so it runs on a clean Mac with nothing installed. The bundle
registers the `bds2://` URL scheme (for blogmark deep links) and ships the red-pen
`AppIcon`. Output: `dist/macos/BDS2.app`. (arm64 / Apple Silicon only.)
### 1. Build the release, then the bundle
```bash
MIX_ENV=prod mix release bds --overwrite
mix bds.bundle.macos --app-release _build/prod/rel/bds
```
The icon `.icns` is generated on first run from
[priv/desktop/macos/icon-source.svg](/Users/gb/Projects/bDS2/priv/desktop/macos/icon-source.svg)
and committed under `priv/desktop/macos/`. Pass `--regen-icon` to rebuild it.
### 2. Register the `bds2://` scheme (optional)
`--register` tells LaunchServices about the scheme without moving the app to
`/Applications` — useful for testing blogmark deep links locally:
```bash
mix bds.bundle.macos --app-release _build/prod/rel/bds --register
```
### Run / smoke-test
```bash
open dist/macos/BDS2.app
open "bds2://new-post?title=Hello&url=https://example.com" # opens a draft
```
### Useful flags
- `--app-release PATH` — release dir to wrap (default `_build/<env>/rel/bds`).
- `--output DIR` — output directory (default `dist/macos`).
- `--version V` — version string for `Info.plist` (default from `mix.exs`).
- `--regen-icon` — regenerate `AppIcon.icns` from the source SVG.
- `--register` — register the `bds2://` scheme via LaunchServices.
- `--no-codesign` — skip the ad-hoc codesign step.
### Verifying the bundle
```bash
plutil -lint dist/macos/BDS2.app/Contents/Info.plist
codesign --verify --deep --strict dist/macos/BDS2.app
# no /opt/homebrew or /usr/local references should appear:
otool -L dist/macos/BDS2.app/Contents/Resources/rel/lib/wx-*/priv/wxe_driver.so
```

191
SPECAUDIT.md Normal file
View File

@@ -0,0 +1,191 @@
# Spec Audit Process
This document describes the repeatable process for auditing the Allium specifications against the bDS2 codebase and test suite. Run it whenever specs or code change materially.
## Overview
The audit produces three categories of findings:
1. **Spec-claims-not-in-code** — spec describes behavior the code does not implement
2. **Code-not-in-spec** — code implements behavior the spec does not describe
3. **Spec-claims-not-in-tests** — spec invariants/rules/behaviors lack test coverage
## Step 1: Map the Territory
```bash
# List all spec files
ls specs/*.allium
# List all source modules
ls lib/bds/ lib/bds/**/
# List all test files
ls test/bds/ test/bds/**/
```
Record the mapping between specs and code/test files. Use `specs/bds.allium` as the index — it lists every `use` directive with its domain label.
## Step 2: Extract Spec Claims
For each `.allium` file, extract:
| Claim Type | Pattern | Example |
|---|---|---|
| **invariant** | `invariant Name:` or lines describing always-true properties | `UniqueSlugPerProject: slugs unique within project` |
| **rule** | `rule Name { requires: ... ensures: ... }` | `CreatePost: creates with slug, status=draft` |
| **guarantee** | `guarantee Name:` | `SandboxedExecution: no filesystem/process loading` |
| **config** | `config { key = value }` | `macro_timeout = 10.seconds` |
| **behavior** | Explicit claims in comments or entity descriptions | `"HomeAlwaysPresent: menu always has Home entry"` |
Record the spec file name, claim name, claim type, and line number for each.
## Step 3: Compare Spec Claims Against Code
For each claim, find the corresponding code and verify:
### 3a. Entity/field existence
- Does the Ecto schema have the fields the spec declares?
- Are relationships (has_many, belongs_to) present?
- Are enum/status values complete?
```bash
# Check schema fields
grep -n "field :" lib/bds/posts/post.ex
grep -n "has_many\|belongs_to" lib/bds/posts/post.ex
```
### 3b. Rule implementation
- Does the code enforce the `requires` preconditions?
- Does the code produce the `ensures` postconditions?
- Are side-effects (FTS, embeddings, file writes) triggered?
```bash
# Check function implementation
grep -n "def create_post" lib/bds/posts.ex
grep -n "def publish_post" lib/bds/posts.ex
```
### 3c. Invariant enforcement
- Are constraints enforced at the schema level (unique_index, check_constraint)?
- Are constraints enforced in changeset validations?
- Are constraints enforced in business logic?
```bash
# Check database constraints
grep -n "unique_index\|check_constraint" priv/repo/migrations/*.ex
grep -n "unique_constraint\|validate_" lib/bds/posts/post.ex
```
### 3d. File format compliance
- Does the serialization format match the spec's frontmatter values?
- Are conditional fields omitted when falsy?
- Are required fields always present?
```bash
# Check serialization
grep -n "serialize\|write_file\|Frontmatter" lib/bds/frontmatter.ex lib/bds/posts/file_sync.ex
```
## Step 4: Compare Code Against Spec Claims
Search for code that implements behavior NOT described in any spec:
### 4a. Public API functions not in any spec rule
```bash
# List public functions in a module
grep -n "def " lib/bds/posts.ex | grep -v "defp"
```
### 4b. Schema fields not in any spec entity
```bash
# List all fields
grep -n "field :" lib/bds/posts/post.ex
```
### 4c. Side effects not in engine_side_effects.allium
```bash
# Check what happens after CRUD operations
grep -n "sync_post\|sync_media\|Search\.\|Embeddings\.\|AutoTranslation" lib/bds/posts.ex lib/bds/media.ex
```
### 4d. UI features not in any editor spec
```bash
# Check HEEx templates for UI elements
grep -n "phx-click\|data-phx-" lib/bds/desktop/post_editor_html/post_editor.html.heex
```
## Step 5: Compare Spec Claims Against Tests
For each invariant, rule, and guarantee, search for a test that verifies it:
### 5a. Direct test search
```bash
# Search test names and bodies
grep -rn "test \"" test/bds/posts_test.exs | head -30
grep -rn "test \"" test/bds/media_test.exs | head -30
```
### 5b. Invariant coverage check
For each invariant, determine:
- **YES**: Test explicitly verifies the invariant (creates violation, expects rejection)
- **PARTIAL**: Test verifies the happy path but not violation scenarios
- **NO**: No test exists
### 5c. Rule coverage check
For each rule, determine:
- **YES**: Test exercises `requires` precondition and `ensures` postcondition
- **PARTIAL**: Test exercises the happy path but not preconditions or all postconditions
- **NO**: No test exists
### 5d. Side-effect chain coverage
For each side-effect rule in `engine_side_effects.allium`, check whether a test verifies ALL `ensures` clauses fire together (not just individually).
## Step 6: Classify Findings
Each gap falls into one of these categories with a recommended action:
| Category | Direction | Action |
|---|---|---|
| **Spec correct, code wrong** | Spec → Code | Fix the code |
| **Code correct, spec drifted** | Code → Spec | Update the spec |
| **Code behavior, no spec** | Code → Spec | Distill into spec |
| **Spec claim, no test** | Spec → Test | Write test |
| **Internal spec inconsistency** | Spec → Spec | Align specs |
| **Decision needed** | Both | Resolve with stakeholder |
## Step 7: Produce SPECGAPS.md
Consolidate all findings into `SPECGAPS.md` with:
- Gap ID for tracking
- Clear description of the gap
- Which spec file and line
- Which code file and line
- Recommended path (fix code / update spec / write test / decide)
- Priority (HIGH/MEDIUM/LOW)
## Step 8: Validate
After making changes:
```bash
# Run full test suite
mix test
# Run dialyzer
mix dialyzer
# Validate allium specs (if tool available)
# Use the allium CLI to validate spec files
```
## Re-running the Audit
1. Start from Step 2 — re-extract claims from updated specs
2. Run Steps 3-5 against current code and tests
3. Compare against previous SPECGAPS.md to identify resolved and new gaps
4. Update SPECGAPS.md
The audit should be re-run after:
- Adding new spec files or significant spec changes
- Adding new features or refactoring code
- Adding new test files
- Before any release milestone

87
TESTAUDIT.md Normal file
View File

@@ -0,0 +1,87 @@
# Test Audit Procedure
Periodic review of the unit test suite to ensure every test exercises production
code against real assumptions and behavior.
## Scope
All `*_test.exs` files under `test/`.
## What counts as a valid unit test
A valid unit test **calls at least one production function** from `lib/bds/` and
**asserts on its return value, side effects, or observable behavior**.
Acceptable patterns:
- Calling a production function and asserting its return value.
- Calling a production function with injected test doubles (fake HTTP clients,
fake runtimes) and asserting the production code's orchestration logic.
- Mounting a LiveView or rendering a LiveComponent and asserting HTML output
or database state after interactions.
- Sending events to a GenServer and asserting state transitions.
### Source-property tests (acceptable, not flagged)
Tests that verify structural properties of source code are acceptable and should
not be flagged during this audit. Examples:
- Checking that all public functions have `@spec` annotations (AST parsing).
- Asserting absence of `String.to_atom` or `cond do` in specific files.
- Verifying CSS/JS/template assets contain expected class names or imports.
- Checking that `API.md` matches the output of a documentation generator.
- Verifying database indexes exist via `EXPLAIN QUERY PLAN`.
- Asserting `.allium` spec files have consistent parameter signatures.
- Checking config files for expected values.
- Verifying function decomposition patterns in source.
These are linting/contract/consistency checks. They serve a purpose but are
distinct from behavioral tests.
## What gets flagged
1. **Export-existence-only tests** — tests that call `function_exported?/3` or
`Code.ensure_loaded?/1` without ever invoking the function. These verify
compilation, not behavior. They are redundant when the same module is already
tested via rendering or direct calls in another test file.
2. **Mock-only tests** — tests that define a fake/stub module and only assert
on that fake's behavior without routing through any production code path.
3. **Trivially-passing tests** — tests whose assertions succeed regardless of
whether the production code is correct (e.g., asserting on a hardcoded value
that never touches production logic).
## How to run the audit
Ask Claude Code to:
> Analyse the unit tests of the project and check if all of them actually call
> proper production code or if there are tests that essentially only test
> scaffolds, mocks and helper functions. Every unit test must test proper
> production code against assumptions and behaviour. Source-property tests
> (structure, @spec, asset presence, schema verification, doc staleness) are
> acceptable and should not be flagged.
The audit should:
1. Read every `*_test.exs` file under `test/` in full.
2. For each test block, identify which production function (if any) is called.
3. Flag any test that falls into the categories above.
4. Report flagged tests with file path, line number, and explanation.
## Audit log
### 2026-05-11
Reviewed all 71 test files (69 after cleanup). Found 2 redundant files:
- `test/bds/desktop/shell_live/chat_editor_test.exs` — single test only called
`function_exported?` for `ChatEditor`. The component was already fully tested
via `render_component` in `shell_live_test.exs`. **Deleted.**
- `test/bds/desktop/shell_live/import_editor_test.exs` — single test only called
`Code.ensure_loaded?` + `function_exported?` for `ImportEditor`. The component
was already exercised in `import_shell_live_test.exs`. **Deleted.**
Result after cleanup: 646 tests, 0 failures, 4 skipped.

View File

@@ -7,6 +7,7 @@
@import "./tokens.css"; @import "./tokens.css";
@import "./shell.css"; @import "./shell.css";
@import "./sidebar.css"; @import "./sidebar.css";
@import "./git_sidebar.css";
@import "./tabs.css"; @import "./tabs.css";
@import "./editor.css"; @import "./editor.css";
@import "./forms.css"; @import "./forms.css";
@@ -16,4 +17,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,482 @@
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;
}
/* Stacked bars: segments sit side by side inside the track. */
.chat-surface-chart-bar-stacked {
display: flex;
}
.chat-surface-chart-bar-segment {
display: block;
height: 100%;
min-width: 0;
transition: width 0.3s ease;
}
/* Shared legend (pie/donut/stacked-bar). */
.chat-surface-chart-legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
font-size: 11px;
}
.chat-surface-chart-legend-item {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--vscode-descriptionForeground);
}
.chat-surface-chart-legend-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
/* Pie / donut. */
.chat-surface-chart-pie {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.chat-surface-chart-pie-svg {
width: 140px;
height: 140px;
}
.chat-surface-chart-pie-slice {
stroke: var(--vscode-editor-background, #1e1e1e);
stroke-width: 1;
}
.chat-surface-chart-donut-hole {
fill: var(--vscode-editor-background, #1e1e1e);
}
.chat-surface-chart-donut-total {
fill: var(--vscode-editor-foreground);
font-size: 16px;
font-weight: 600;
}
/* Line / area. */
.chat-surface-chart-line-svg {
width: 100%;
height: auto;
}
.chat-surface-chart-line-grid {
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 1;
}
.chat-surface-chart-line-y-label,
.chat-surface-chart-line-x-label {
fill: var(--vscode-descriptionForeground);
font-size: 9px;
}
.chat-surface-chart-line-path {
stroke: var(--accent-color);
stroke-width: 2;
}
.chat-surface-chart-area-fill {
fill: var(--accent-color);
opacity: 0.18;
}
.chat-surface-chart-line-dot {
fill: var(--accent-color);
}
/* Heatmap. */
.chat-surface-chart-heatmap {
display: grid;
gap: 2px;
font-size: 11px;
}
.chat-surface-chart-heatmap-corner {
/* empty top-left cell */
}
.chat-surface-chart-heatmap-col-label {
text-align: center;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-surface-chart-heatmap-row-label {
text-align: right;
padding-right: 4px;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 80px;
}
.chat-surface-chart-heatmap-cell {
aspect-ratio: 1;
min-width: 14px;
min-height: 14px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
/* ── 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 +625,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 +691,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;
}

294
assets/css/git_sidebar.css Normal file
View File

@@ -0,0 +1,294 @@
/* ── Git sidebar ─────────────────────────────────────────────────────── */
.git-sidebar {
display: flex;
flex-direction: column;
}
/* ── Header ──────────────────────────────────────────────────────────── */
.git-header {
padding: 8px 12px 12px;
border-bottom: 1px solid var(--vscode-sideBar-border);
display: flex;
flex-direction: column;
gap: 6px;
}
.git-branch {
font-size: 13px;
font-weight: 600;
color: var(--vscode-sideBar-foreground);
}
.git-branch-icon {
font-size: 14px;
color: var(--vscode-descriptionForeground);
flex-shrink: 0;
}
.git-upstream {
font-size: 11px;
color: var(--vscode-descriptionForeground);
padding: 1px 6px;
border-radius: 999px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.git-ahead {
font-size: 11px;
color: var(--vscode-testing-iconPassed);
}
.git-behind {
font-size: 11px;
color: var(--vscode-notificationsInfoIcon-foreground);
}
/* ── Sync legend ─────────────────────────────────────────────────────── */
.git-legend-item {
font-size: 10px;
color: var(--vscode-descriptionForeground);
display: flex;
align-items: center;
gap: 4px;
}
.git-sync-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.git-sync-synced {
background-color: var(--vscode-testing-iconPassed);
}
.git-sync-local_only {
background-color: var(--vscode-editorWarning-foreground);
}
.git-sync-remote_only {
background-color: var(--vscode-notificationsInfoIcon-foreground);
}
/* ── Actions ─────────────────────────────────────────────────────────── */
.git-actions {
border-bottom: 1px solid var(--vscode-sideBar-border);
padding: 8px 12px;
}
.git-action-button {
flex: 1;
padding: 4px 8px;
font-size: 11px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s;
text-align: center;
}
.git-action-button:hover:not(:disabled) {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.git-action-button:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Sections ────────────────────────────────────────────────────────── */
.git-section {
padding: 0;
}
.git-section-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-sideBar-foreground);
}
.git-section-count {
font-size: 10px;
color: var(--vscode-descriptionForeground);
}
/* ── Forms ───────────────────────────────────────────────────────────── */
.git-commit-form,
.git-init-form {
padding: 8px 12px;
border-bottom: 1px solid var(--vscode-sideBar-border);
}
.git-commit-form input,
.git-init-form input {
padding: 4px 8px;
font-size: 12px;
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
color: var(--vscode-input-foreground);
border-radius: 3px;
}
.git-commit-form input::placeholder,
.git-init-form input::placeholder {
color: var(--vscode-input-placeholderForeground);
}
.git-commit-form input:focus,
.git-init-form input:focus {
outline: none;
border-color: var(--vscode-focusBorder);
}
.git-commit-form .git-action-button,
.git-init-form .git-action-button {
flex: 1;
}
/* ── Status file list ────────────────────────────────────────────────── */
.git-status-file {
width: 100%;
padding: 5px 12px;
border: none;
border-radius: 0;
background: transparent;
color: var(--vscode-sideBar-foreground);
cursor: pointer;
text-align: left;
font-size: 12px;
}
.git-status-file:hover {
background-color: var(--vscode-list-hoverBackground);
}
.git-status-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.git-status-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
flex-shrink: 0;
text-transform: uppercase;
}
.git-status-added {
color: var(--vscode-testing-iconPassed);
background: color-mix(in srgb, var(--vscode-testing-iconPassed) 15%, transparent);
}
.git-status-modified {
color: var(--vscode-editorWarning-foreground);
background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 15%, transparent);
}
.git-status-deleted {
color: var(--vscode-errorForeground);
background: color-mix(in srgb, var(--vscode-errorForeground) 15%, transparent);
}
.git-status-renamed {
color: var(--vscode-notificationsInfoIcon-foreground);
background: color-mix(in srgb, var(--vscode-notificationsInfoIcon-foreground) 15%, transparent);
}
.git-status-untracked {
color: var(--vscode-descriptionForeground);
background: color-mix(in srgb, var(--vscode-descriptionForeground) 15%, transparent);
}
/* ── Commit history list ─────────────────────────────────────────────── */
.git-history-entry {
width: 100%;
padding: 5px 12px;
border: none;
border-radius: 0;
background: transparent;
color: var(--vscode-sideBar-foreground);
cursor: pointer;
text-align: left;
}
.git-history-entry:hover {
background-color: var(--vscode-list-hoverBackground);
}
.git-history-subject {
font-size: 12px;
color: var(--vscode-sideBar-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.git-history-meta {
margin-top: 2px;
}
.git-history-hash,
.git-history-author,
.git-history-date {
font-size: 10px;
color: var(--vscode-descriptionForeground);
}
.git-history-hash {
font-family: var(--vscode-editor-font-family);
}
.git-history-more {
padding: 8px 12px;
font-size: 11px;
color: var(--vscode-descriptionForeground);
text-align: center;
}
/* ── Empty / Not-a-repo state ────────────────────────────────────────── */
.git-not-a-repo {
padding: 12px;
}
.git-empty-hint {
font-size: 12px;
color: var(--vscode-descriptionForeground);
padding: 8px 12px;
}
/* ── Divider between sections ────────────────────────────────────────── */
.git-section + .git-section {
border-top: 1px solid var(--vscode-sideBar-border);
}
/* ── Section header that matches sidebar-section-header ───────────────── */
.git-header + .git-actions {
border-top: 1px solid var(--vscode-sideBar-border);
}

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

@@ -579,6 +579,13 @@
cursor: pointer; cursor: pointer;
} }
.task-message-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.status-bar-item.theme-badge { .status-bar-item.theme-badge {
border: 1px solid rgba(255, 255, 255, 0.18); border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 3px; border-radius: 3px;
@@ -595,14 +602,16 @@
background: transparent; background: transparent;
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
opacity: 0.4;
font-size: 13px; font-size: 13px;
padding: 0 4px; padding: 0 4px;
} }
.status-bar-item.offline-badge.active { .status-bar-item.offline-badge.active {
background-color: rgba(255, 196, 0, 0.28); background-color: #e6a800;
color: #000;
font-weight: 600;
opacity: 1; opacity: 1;
border-radius: 3px;
} }
.project-selector { .project-selector {

View File

@@ -3,6 +3,9 @@ export const ChatSurface = {
this.stickToBottom = true; this.stickToBottom = true;
this.scrollContainer = null; this.scrollContainer = null;
this._enterKeyHandled = false;
this._prevInputValue = "";
this.autoResize = () => { this.autoResize = () => {
const textarea = this.el.querySelector(".chat-input"); const textarea = this.el.querySelector(".chat-input");
@@ -85,11 +88,34 @@ export const ChatSurface = {
this.stickToBottom = distanceFromBottom < 48; this.stickToBottom = distanceFromBottom < 48;
}; };
this._submitChat = () => {
const form = this.el.querySelector(".chat-input-wrapper");
if (form && typeof form.requestSubmit === "function") {
form.requestSubmit();
} else {
const sendButton = this.el.querySelector("[data-testid='chat-send-button']");
if (sendButton) sendButton.click();
}
};
this.handleInput = (event) => { this.handleInput = (event) => {
if (!event.target.closest(".chat-input")) { if (!event.target.closest(".chat-input")) {
return; return;
} }
const textarea = event.target;
if (!this._enterKeyHandled && textarea.value.includes("\n") && !this._prevInputValue.includes("\n")) {
textarea.value = textarea.value.replace(/\n/g, "");
this._prevInputValue = textarea.value;
this.stickToBottom = true;
this.autoResize();
this._submitChat();
return;
}
this._enterKeyHandled = false;
this._prevInputValue = textarea.value;
this.stickToBottom = true; this.stickToBottom = true;
this.autoResize(); this.autoResize();
}; };
@@ -101,12 +127,8 @@ export const ChatSurface = {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault(); event.preventDefault();
this._enterKeyHandled = true;
const sendButton = this.el.querySelector("[data-testid='chat-send-button']"); this._submitChat();
if (sendButton && !sendButton.disabled) {
sendButton.click();
}
} }
}; };

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

@@ -118,6 +118,36 @@ export const MonacoEditor = {
}, 120); }, 120);
}; };
this.dropEvent = this.el.dataset.monacoDropEvent || "";
this.dropPostId = this.el.dataset.monacoDropPostId || "";
this.handleDragOver = (event) => {
if (event.dataTransfer && Array.from(event.dataTransfer.types || []).includes("Files")) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
};
this.handleDrop = (event) => {
if (!this.dropEvent || !event.dataTransfer) {
return;
}
const files = Array.from(event.dataTransfer.files || []);
const images = files.filter((file) => (file.type || "").startsWith("image/") && file.path);
if (images.length === 0) {
return;
}
event.preventDefault();
event.stopPropagation();
images.forEach((file) => {
this.pushEvent(this.dropEvent, { "post-id": this.dropPostId, path: file.path });
});
};
this.handleInsert = ({ id, content }) => { this.handleInsert = ({ id, content }) => {
if (!this.editor || !content || String(id) !== String(this.editorId)) { if (!this.editor || !content || String(id) !== String(this.editorId)) {
return; return;
@@ -197,6 +227,11 @@ export const MonacoEditor = {
if (this.insertEvent) { if (this.insertEvent) {
this.handleEvent(this.insertEvent, this.handleInsert); this.handleEvent(this.insertEvent, this.handleInsert);
} }
if (this.dropEvent) {
this.el.addEventListener("dragover", this.handleDragOver);
this.el.addEventListener("drop", this.handleDrop);
}
}) })
.catch((error) => { .catch((error) => {
console.error("Failed to load Monaco editor", error); console.error("Failed to load Monaco editor", error);
@@ -232,6 +267,12 @@ export const MonacoEditor = {
window.clearTimeout(this.syncTimer); window.clearTimeout(this.syncTimer);
this.visibleSizeObserver?.disconnect(); this.visibleSizeObserver?.disconnect();
this.changeSubscription?.dispose(); this.changeSubscription?.dispose();
if (this.dropEvent) {
this.el.removeEventListener("dragover", this.handleDragOver);
this.el.removeEventListener("drop", this.handleDrop);
}
unregisterMonacoEditor(this.editorId || this.el.id); unregisterMonacoEditor(this.editorId || this.el.id);
this.editor?.dispose(); this.editor?.dispose();
} }

View File

@@ -4,7 +4,9 @@ config :bds,
ecto_repos: [BDS.Repo] ecto_repos: [BDS.Repo]
config :bds, BDS.Repo, config :bds, BDS.Repo,
database: Path.expand("../priv/data/bds_dev.db", __DIR__), database:
System.get_env("BDS_DATABASE_PATH") ||
Path.expand("~/Library/Application Support/BDS2/bds_dev.db"),
pool_size: 5, pool_size: 5,
journal_mode: :wal, journal_mode: :wal,
busy_timeout: 15_000, busy_timeout: 15_000,
@@ -21,6 +23,9 @@ config :bds, :desktop,
title: "Blogging Desktop Server", title: "Blogging Desktop Server",
secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001" secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
config :bds, :ai_secret_key,
"bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
config :bds, BDS.Desktop.Endpoint, config :bds, BDS.Desktop.Endpoint,
url: [host: "127.0.0.1"], url: [host: "127.0.0.1"],
adapter: Bandit.PhoenixAdapter, adapter: Bandit.PhoenixAdapter,
@@ -58,12 +63,31 @@ config :bds, :scripting,
timeout: 300_000, timeout: 300_000,
max_reductions: 5_000_000, max_reductions: 5_000_000,
job_timeout: :infinity, job_timeout: :infinity,
job_max_reductions: :none job_max_reductions: :none,
transform_max_toasts_per_script: 5,
transform_max_toasts_total: 20,
transform_max_toast_length: 300
config :bds, :chat, max_tool_rounds: 10
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,
# Hardware acceleration: :auto prefers the Apple GPU (EMLX/Metal) on Apple
# Silicon and falls back to EXLA-CPU elsewhere. Force with :emlx or :exla.
accelerator: :auto
# 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,
System.get_env("BDS_MODEL_CACHE_DIR") ||
Path.expand("~/Library/Application Support/BDS2/models")
config :logger, :console, config :logger, :console,
format: "$time $metadata[$level] $message\n", format: "$time $metadata[$level] $message\n",

View File

@@ -1,7 +1,6 @@
import Config import Config
config :bds, BDS.Repo, config :bds, BDS.Repo,
database: Path.expand("../priv/data/bds_prod.db", __DIR__),
pool_size: 5, pool_size: 5,
stacktrace: false, stacktrace: false,
show_sensitive_data_on_connection_error: false show_sensitive_data_on_connection_error: false

View File

@@ -3,9 +3,17 @@ import Config
if config_env() == :prod do if config_env() == :prod do
database_path = database_path =
System.get_env("BDS_DATABASE_PATH") || System.get_env("BDS_DATABASE_PATH") ||
Path.expand("../priv/data/bds_prod.db", __DIR__) Path.expand("~/Library/Application Support/BDS2/bds.db")
File.mkdir_p!(Path.dirname(database_path))
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
@@ -375,7 +411,7 @@ defmodule BDS.AI.Chat do
tools, tools,
runtime, runtime,
opts, opts,
@chat_max_tool_rounds chat_max_tool_rounds()
), ),
{:ok, reply} <- {:ok, reply} <-
maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
@@ -716,6 +752,14 @@ defmodule BDS.AI.Chat do
ChatTools.available_specs(project_id, Catalog.model_capabilities(model)) ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
end end
# BoundedToolLoop: the tool-calling round count is capped by
# config.chat_max_tool_rounds (falling back to the built-in default).
defp chat_max_tool_rounds do
:bds
|> Application.get_env(:chat, [])
|> Keyword.get(:max_tool_rounds, @chat_max_tool_rounds)
end
defp chat_system_prompt(project_id, tools) do defp chat_system_prompt(project_id, tools) do
base = get_setting("ai.system_prompt") || @default_system_prompt base = get_setting("ai.system_prompt") || @default_system_prompt
@@ -742,15 +786,16 @@ defmodule BDS.AI.Chat do
"- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.", "- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.",
"If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.", "If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.",
"", "",
"Available UI Render Tools:", "Available UI Render Tools (use these to show rich interactive elements):",
"- Use render_chart to show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use it when presenting statistics or comparisons. Prefer heatmap over tables with emoji or color indicators for intensity grids or calendar-style activity.", "- render_chart: Show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use when presenting statistics or comparisons. Use stacked-bar when each bar has multiple segments (e.g., published vs draft posts per year). Use area for cumulative or trend data where the filled region emphasizes volume. Use donut for proportional breakdowns with a total displayed in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude — e.g., posts per month across years (each series entry is a row like a year, each segment is a column like a month), or a calendar view where rows are weekdays and columns are week numbers. ALWAYS prefer heatmap over a table with emojis or color indicators when showing intensity grids or calendar-style activity views. IMPORTANT: a heatmap needs structured data — each entry in 'series' is a ROW and must include a 'segments' array whose entries are the COLUMNS (every segment needs a 'label' and a numeric 'value'); the row's own 'value' is ignored. Plan what the rows and columns represent before fetching data (e.g. rows = years, columns = months). A heatmap sent without segments renders empty.",
"- Use render_table for tabular data, comparisons, and structured listings.", "- render_table: Show data in a structured table. Use for tabular comparisons and listings.",
"- Use render_form to collect structured user input.", "- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).",
"- Use render_card for summaries, highlights, or actionable items.", "- render_card: Show an information card with title, body, and action buttons.",
"- Use render_metric for a single KPI or important statistic.", "- render_metric: Show a single KPI or statistic prominently.",
"- Use render_list for bullet lists, checklists, or simple enumerations.", "- render_list: Show a bulleted list of items.",
"- Use render_tabs to organize multiple views into switchable tabs; tab content can contain text, metrics, lists, charts, and tables.", "- render_tabs: Organize information into switchable tabs. Tab content supports all content types: text, metrics, lists, charts, and tables.",
"When presenting data, statistics, or comparisons, prefer render tools over plain text. When building any visualization, render it as soon as you have enough data." "",
"When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text. When you need user input for a multi-field operation, use render_form to present a structured form. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media). When comparing data across multiple dimensions (e.g., statistics per year), use render_tabs with embedded charts or tables in each tab. When building any visualization, render it as soon as you have enough data."
], ],
"\n" "\n"
) )

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,16 @@ 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

@@ -803,10 +803,40 @@ defmodule BDS.AI.ChatTools do
%{ %{
"type" => "object", "type" => "object",
"properties" => %{ "properties" => %{
"title" => %{"type" => "string"}, "title" => %{"type" => "string", "description" => "Optional form title"},
"fields" => %{"type" => "array"}, "fields" => %{
"submitLabel" => %{"type" => "string"}, "type" => "array",
"submitAction" => %{"type" => "string"} "description" => "Form fields to display",
"items" => %{
"type" => "object",
"properties" => %{
"key" => %{"type" => "string", "description" => "Field identifier"},
"label" => %{"type" => "string", "description" => "Field label shown to user"},
"inputType" => %{
"type" => "string",
"enum" => ["text", "textarea", "select", "checkbox", "date", "number"],
"description" => "Type of input control"
},
"placeholder" => %{"type" => "string", "description" => "Placeholder text"},
"defaultValue" => %{"type" => "string", "description" => "Default value"},
"options" => %{
"type" => "array",
"description" => "Options for select fields",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "string"}
}
}
},
"required" => %{"type" => "boolean", "description" => "Whether the field is required"}
},
"required" => ["key", "label", "inputType"]
}
},
"submitLabel" => %{"type" => "string", "description" => "Label for the submit button"},
"submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"}
} }
} }
end end
@@ -815,10 +845,25 @@ defmodule BDS.AI.ChatTools do
%{ %{
"type" => "object", "type" => "object",
"properties" => %{ "properties" => %{
"title" => %{"type" => "string"}, "title" => %{"type" => "string", "description" => "Card title"},
"subtitle" => %{"type" => "string"}, "subtitle" => %{"type" => "string", "description" => "Optional subtitle"},
"body" => %{"type" => "string"}, "body" => %{"type" => "string", "description" => "Card body text (supports markdown)"},
"actions" => %{"type" => "array"} "actions" => %{
"type" => "array",
"description" => "Optional action buttons on the card",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Button label"},
"action" => %{"type" => "string", "description" => "Action name to dispatch"},
"payload" => %{
"type" => "object",
"description" => "Optional action payload"
}
},
"required" => ["label", "action"]
}
}
} }
} }
end end
@@ -827,8 +872,8 @@ defmodule BDS.AI.ChatTools do
%{ %{
"type" => "object", "type" => "object",
"properties" => %{ "properties" => %{
"label" => %{"type" => "string"}, "label" => %{"type" => "string", "description" => "Metric label"},
"value" => %{"type" => "string"} "value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
} }
} }
end end
@@ -837,8 +882,12 @@ defmodule BDS.AI.ChatTools do
%{ %{
"type" => "object", "type" => "object",
"properties" => %{ "properties" => %{
"title" => %{"type" => "string"}, "title" => %{"type" => "string", "description" => "Optional list title"},
"items" => %{"type" => "array"} "items" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "List items"
}
} }
} }
end end
@@ -847,8 +896,58 @@ defmodule BDS.AI.ChatTools do
%{ %{
"type" => "object", "type" => "object",
"properties" => %{ "properties" => %{
"title" => %{"type" => "string"}, "title" => %{"type" => "string", "description" => "Optional tabs title"},
"tabs" => %{"type" => "array"} "tabs" => %{
"type" => "array",
"description" => "Array of tabs",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Tab label"},
"content" => %{
"type" => "array",
"description" => "Content items within the tab",
"items" => %{
"type" => "object",
"properties" => %{
"type" => %{
"type" => "string",
"enum" => ["text", "metric", "list", "chart", "table"],
"description" => "Content type"
},
"text" => %{"type" => "string", "description" => "Text content (for type text)"},
"label" => %{"type" => "string", "description" => "Label (for type metric)"},
"value" => %{"type" => "string", "description" => "Display value (for type metric)"},
"title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"},
"items" => %{
"type" => "array", "items" => %{"type" => "string"},
"description" => "Items (for type list)"
},
"chartType" => %{
"type" => "string",
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
"description" => "Chart type (for type chart)"
},
"series" => %{
"type" => "array",
"description" => "Data series (for type chart)"
},
"columns" => %{
"type" => "array", "items" => %{"type" => "string"},
"description" => "Column headers (for type table)"
},
"rows" => %{
"type" => "array", "items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows (for type table)"
}
},
"required" => ["type"]
}
}
},
"required" => ["label", "content"]
}
}
} }
} }
end end
@@ -857,8 +956,24 @@ defmodule BDS.AI.ChatTools do
%{ %{
"type" => "object", "type" => "object",
"properties" => %{ "properties" => %{
"title" => %{"type" => "string"}, "title" => %{"type" => "string", "description" => "Optional mind map title"},
"nodes" => %{"type" => "array"} "nodes" => %{
"type" => "array",
"description" => "Flat array of nodes. The first node is the root. Each node references children by ID.",
"items" => %{
"type" => "object",
"properties" => %{
"id" => %{"type" => "string", "description" => "Unique node identifier"},
"label" => %{"type" => "string", "description" => "Node label text"},
"children" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "IDs of child nodes"
}
},
"required" => ["id", "label"]
}
}
} }
} }
end end

View File

@@ -25,7 +25,8 @@ defmodule BDS.Application do
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
children = [ children =
[
{Phoenix.PubSub, name: BDS.PubSub}, {Phoenix.PubSub, name: BDS.PubSub},
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()}, {BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
BDS.Repo, BDS.Repo,
@@ -37,14 +38,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
@@ -62,7 +73,8 @@ defmodule BDS.Application do
[ [
{Desktop.Window, window_opts}, {Desktop.Window, window_opts},
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher) Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher),
{BDS.Desktop.DeepLink, []}
] ]
end end
end end

150
lib/bds/blogmark.ex Normal file
View File

@@ -0,0 +1,150 @@
defmodule BDS.Blogmark do
@moduledoc """
Receives `bds2://new-post` blogmark deep links and turns them into draft posts
(spec: script.allium `BlogmarkReceived`/`ExecuteTransform`,
editor_settings.allium `BookmarkletCopy`).
The browser bookmarklet (`BDS.Scripting.Capabilities.AppShell`) navigates to a
`bds2://new-post?title=&url=` URL. The desktop layer hands that URL here, where
it is parsed into a post candidate, run through the enabled transform pipeline
(`BDS.Scripts.Transforms`), and finally persisted as a draft post — defaulting
the category from the project's `blogmark_category` setting when neither the
link nor a transform supplied one.
The `bds2://` scheme deliberately differs from the legacy app's `bds://` scheme
so the two installs do not fight over the same registration.
"""
alias BDS.Metadata
alias BDS.Posts
alias BDS.Scripts.Transforms
@scheme "bds2"
@new_post_action "new-post"
@type candidate :: %{required(String.t()) => term()}
@type receive_result :: %{
post: Posts.Post.t(),
toasts: [String.t()],
errors: [%{slug: String.t() | nil, reason: term()}]
}
@doc """
Parses a `bds2://new-post` deep link into a post candidate map.
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
`tags` and `categories`, or an error when the scheme or action is unsupported.
"""
@spec parse_deep_link(String.t()) ::
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
def parse_deep_link(url) when is_binary(url) do
case URI.parse(url) do
%URI{scheme: @scheme, host: @new_post_action, query: query} ->
{:ok, candidate_from_query(query)}
%URI{scheme: @scheme} ->
{:error, :unsupported_action}
%URI{scheme: nil} ->
{:error, :invalid_url}
%URI{} ->
{:error, :unsupported_scheme}
end
end
@doc """
Receives a blogmark deep link for `project_id`: parses it, runs the transform
pipeline, and creates a draft post from the resulting candidate.
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
budget-enforced transform messages and `errors` records any failed transforms.
"""
@spec receive_deep_link(String.t(), String.t(), keyword()) ::
{:ok, receive_result()} | {:error, term()}
def receive_deep_link(project_id, url, opts \\ [])
when is_binary(project_id) and is_binary(url) and is_list(opts) do
with {:ok, candidate} <- parse_deep_link(url),
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
Transforms.run(project_id, candidate, opts),
data <- apply_default_category(project_id, data),
{:ok, post} <- create_draft(project_id, data) do
{:ok, %{post: post, toasts: toasts, errors: errors}}
end
end
defp candidate_from_query(query) do
params = URI.decode_query(query || "")
%{
"title" => Map.get(params, "title", "") |> to_string(),
"url" => optional(params, "url"),
"content" => optional(params, "content"),
"tags" => list_param(params, "tags"),
"categories" => list_param(params, "categories")
}
end
defp optional(params, key) do
case Map.get(params, key) do
nil -> nil
"" -> nil
value -> value
end
end
defp list_param(params, key) do
case Map.get(params, key) do
value when is_binary(value) ->
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
_ ->
[]
end
end
# Apply the project default category only when neither the deep link nor a
# transform produced one, so explicit categories always win.
defp apply_default_category(project_id, data) do
case Map.get(data, "categories") do
categories when is_list(categories) and categories != [] ->
data
_ ->
case default_category(project_id) do
nil -> data
category -> Map.put(data, "categories", [category])
end
end
end
defp default_category(project_id) do
case Metadata.get_project_metadata(project_id) do
{:ok, %{blogmark_category: category}} when is_binary(category) and category != "" ->
category
_ ->
nil
end
end
defp create_draft(project_id, data) do
Posts.create_post(%{
project_id: project_id,
title: Map.get(data, "title", ""),
content: optional_string(Map.get(data, "content")),
tags: string_list(Map.get(data, "tags")),
categories: string_list(Map.get(data, "categories"))
})
end
defp optional_string(value) when is_binary(value), do: value
defp optional_string(_value), do: nil
defp string_list(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
defp string_list(_other), do: []
end

View File

@@ -0,0 +1,75 @@
defmodule BDS.Desktop.DeepLink do
@moduledoc """
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
shell (spec: script.allium `BlogmarkReceived`).
On macOS the `BDS2.app` bundle registers `bds2://` as a custom URL scheme via
the `CFBundleURLTypes` entry in its `Info.plist` (built by `BDS.MacBundle` /
`mix bds.bundle.macos`). When the browser bookmarklet navigates to
`bds2://new-post?title=&url=`, the OS launches/raises the app and `Desktop.Env`
delivers an `{:open_url, [url]}` event. This GenServer subscribes to those
events and forwards recognised `bds2://` links to the live shell over PubSub,
where `BDS.Blogmark` turns them into draft posts.
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
installs do not contend for the same registration.
"""
use GenServer
require Logger
alias BDS.CliSync.Watcher
@scheme "bds2://"
def child_spec(opts) do
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}}
end
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@impl true
def init(opts) do
pubsub = Keyword.get(opts, :pubsub, BDS.PubSub)
topic = Keyword.get(opts, :topic, Watcher.topic())
subscribe_to_env()
{:ok, %{pubsub: pubsub, topic: topic}}
end
# Desktop.Env delivers OS events as {event_name, args} tuples.
@impl true
def handle_info({:open_url, [url | _rest]}, state) when is_binary(url) do
{:noreply, route(url, state)}
end
def handle_info(_message, state), do: {:noreply, state}
defp route(url, state) do
if String.starts_with?(url, @scheme) do
Phoenix.PubSub.broadcast(state.pubsub, state.topic, {:blogmark_deep_link, url})
else
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
end
state
end
# Desktop.Env is only present when the wx desktop adapter is running. Guard the
# subscribe so the GenServer can still start in headless/test configurations.
defp subscribe_to_env do
if Process.whereis(Desktop.Env) do
try do
Desktop.Env.subscribe()
catch
:exit, _reason -> :ok
end
end
:ok
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

@@ -190,7 +190,7 @@ defmodule BDS.Desktop.MainWindow do
end end
defp config_dir do defp config_dir do
case :filename.basedir(:user_config, "bds") do case :filename.basedir(:user_config, "BDS2") do
path when is_list(path) -> List.to_string(path) path when is_list(path) -> List.to_string(path)
path -> path path -> path
end end

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
@@ -48,29 +49,32 @@ defmodule BDS.Desktop.Overlay do
end end
def open(:media, :confirm_delete, context) do def open(:media, :confirm_delete, context) do
delete_details = Map.get(context, :delete_details, %{}) %{
title: title,
entity_name: entity_name,
entity_type: entity_type,
reference_list: reference_list
} = context.delete_details
%{ %{
kind: :confirm_delete, kind: :confirm_delete,
title: Map.get(delete_details, :title, "Delete"), title: title,
entity_name: Map.get(delete_details, :entity_name, ""), entity_name: entity_name,
entity_type: Map.get(delete_details, :entity_type, "media"), entity_type: entity_type,
reference_count: length(Map.get(delete_details, :reference_list, [])), reference_count: length(reference_list),
reference_list: Map.get(delete_details, :reference_list, []) reference_list: reference_list
} }
end end
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context) def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
def open(:tags, :confirm_merge, context) do def open(:tags, :confirm_merge, context) do
merge = Map.get(context, :merge_details, %{}) %{title: title, message: message} = context.merge_details
target = Map.get(merge, :target, "")
count = Map.get(merge, :count, 0)
%{ %{
kind: :confirm_dialog, kind: :confirm_dialog,
title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"), title: title,
message: Map.get(merge, :message, "Cannot be undone.") message: message
} }
end end
@@ -115,8 +119,8 @@ defmodule BDS.Desktop.Overlay do
|> Map.get(:all_media, []) |> Map.get(:all_media, [])
|> Enum.filter(fn media -> |> Enum.filter(fn media ->
normalized == "" or normalized == "" or
search_matches?(Map.get(media, :title, ""), normalized) or search_matches?(media.title, normalized) or
search_matches?(Map.get(media, :original_name, ""), normalized) search_matches?(media.original_name, normalized)
end) end)
|> Enum.map(&to_insert_media_result/1) |> Enum.map(&to_insert_media_result/1)
@@ -203,18 +207,22 @@ defmodule BDS.Desktop.Overlay do
def insert_media_result(_overlay, _media_id), do: nil def insert_media_result(_overlay, _media_id), do: nil
defp language_picker(context, source_language) do defp language_picker(context, source_language) do
existing_translations = Map.get(context, :existing_translations, %{})
language_names = Map.get(context, :language_names, %{})
language_flags = Map.get(context, :language_flags, %{})
targets = targets =
context context
|> Map.get(:blog_languages, []) |> Map.get(:blog_languages, [])
|> Enum.uniq() |> Enum.uniq()
|> Enum.reject(&(&1 == source_language)) |> Enum.reject(&(&1 == source_language))
|> Enum.map(fn code -> |> Enum.map(fn code ->
existing_status = Map.get(Map.get(context, :existing_translations, %{}), code) existing_status = Map.get(existing_translations, code)
%{ %{
code: code, code: code,
name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)), name: Map.get(language_names, code, String.upcase(code)),
flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code), flag_emoji: Map.get(language_flags, code, code),
has_existing_translation: not is_nil(existing_status), has_existing_translation: not is_nil(existing_status),
existing_status: existing_status existing_status: existing_status
} }
@@ -255,14 +263,20 @@ defmodule BDS.Desktop.Overlay do
def set_ai_suggestions_error(overlay, _error_message), do: overlay def set_ai_suggestions_error(overlay, _error_message), do: overlay
defp normalize_ai_fields(fields) do defp normalize_ai_fields(fields) do
Enum.map(fields, fn field -> Enum.map(fields, fn %{
key: key,
label: label,
current_value: current,
suggested_value: suggested,
locked: locked
} = field ->
%{ %{
key: to_string(Map.get(field, :key, "")), key: to_string(key),
label: Map.get(field, :label, ""), label: label,
current_value: Map.get(field, :current_value, ""), current_value: current,
suggested_value: Map.get(field, :suggested_value, ""), suggested_value: suggested,
accepted: not Map.get(field, :locked, false), accepted: not locked,
locked: Map.get(field, :locked, false), locked: locked,
loading: Map.get(field, :loading, false) loading: Map.get(field, :loading, false)
} }
end) end)
@@ -276,7 +290,7 @@ defmodule BDS.Desktop.Overlay do
end end
defp gallery_images(context) do defp gallery_images(context) do
images = Enum.filter(Map.get(context, :media, []), &Map.get(&1, :is_image, false)) images = Enum.filter(Map.get(context, :media, []), & &1.is_image)
post_media_ids = Map.get(context, :post_media_ids, []) post_media_ids = Map.get(context, :post_media_ids, [])
case Enum.filter(images, &(&1.id in post_media_ids)) do case Enum.filter(images, &(&1.id in post_media_ids)) do
@@ -289,29 +303,29 @@ defmodule BDS.Desktop.Overlay do
%{ %{
post_id: post.id, post_id: post.id,
title: post.title, title: post.title,
status: to_string(Map.get(post, :status, "draft")), status: post.status,
canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"), canonical_url: post.canonical_url,
similarity_score: Map.get(post, :similarity_score) similarity_score: nil
} }
end end
defp to_insert_media_result(media) do defp to_insert_media_result(media) do
%{ %{
media_id: media.id, media_id: media.id,
title: Map.get(media, :title, ""), title: media.title,
original_name: Map.get(media, :original_name, media.id), original_name: media.original_name,
is_image: Map.get(media, :is_image, false), is_image: media.is_image,
thumbnail_url: Map.get(media, :thumbnail_url) thumbnail_url: media.thumbnail_url
} }
end end
defp to_gallery_image(media) do defp to_gallery_image(media) do
%{ %{
media_id: media.id, media_id: media.id,
thumbnail_url: Map.get(media, :thumbnail_url), thumbnail_url: media.thumbnail_url,
image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)), image_url: media.image_url,
alt_text: Map.get(media, :alt_text), alt_text: media.alt_text,
title: Map.get(media, :title, Map.get(media, :original_name, media.id)) title: media.title
} }
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
@@ -449,7 +440,7 @@ defmodule BDS.Desktop.ShellCommands do
end end
defp translation_fill_enabled?(metadata) do defp translation_fill_enabled?(metadata) do
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, [])) ([metadata.main_language] ++ metadata.blog_languages)
|> Enum.map(fn language -> |> Enum.map(fn language ->
language language
|> to_string() |> to_string()
@@ -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, Blogmark, 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})
@@ -375,15 +398,6 @@ defmodule BDS.Desktop.ShellLive do
def handle_event("overlay_confirm", params, socket), def handle_event("overlay_confirm", params, socket),
do: OverlayManager.handle_event("overlay_confirm", params, socket, overlay_callbacks()) do: OverlayManager.handle_event("overlay_confirm", params, socket, overlay_callbacks())
def handle_event("overlay_select_gallery_image", params, socket),
do:
OverlayManager.handle_event(
"overlay_select_gallery_image",
params,
socket,
overlay_callbacks()
)
def handle_event("overlay_close_lightbox", params, socket), def handle_event("overlay_close_lightbox", params, socket),
do: OverlayManager.handle_event("overlay_close_lightbox", params, socket, overlay_callbacks()) do: OverlayManager.handle_event("overlay_close_lightbox", params, socket, overlay_callbacks())
@@ -399,6 +413,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 +631,87 @@ 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({:blogmark_deep_link, url}, socket) when is_binary(url) do
{:noreply, handle_blogmark_deep_link(socket, url)}
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 +725,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
@@ -861,6 +997,56 @@ defmodule BDS.Desktop.ShellLive do
defp create_sidebar_item(socket, kind), defp create_sidebar_item(socket, kind),
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks()) do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
# Receive a bds2://new-post blogmark deep link: run the transform pipeline,
# create a draft post, open it in the editor, and surface transform toasts.
defp handle_blogmark_deep_link(socket, url) do
title = dgettext("ui", "Blogmark")
case current_project_id(socket) do
project_id when is_binary(project_id) ->
case Blogmark.receive_deep_link(project_id, url) do
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
socket
|> reload_shell(socket.assigns.workbench)
|> open_sidebar_item(
%{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => post.slug
},
:pin
)
|> append_blogmark_toasts(title, toasts)
|> append_blogmark_errors(title, errors)
{:error, reason} ->
append_output_entry(socket, title, inspect(reason), url, "error")
end
_ ->
append_output_entry(
socket,
title,
dgettext("ui", "Open a project before importing a blogmark."),
url,
"warning"
)
end
end
defp append_blogmark_toasts(socket, title, toasts) do
Enum.reduce(toasts, socket, fn message, acc ->
append_output_entry(acc, title, message, nil, "info")
end)
end
defp append_blogmark_errors(socket, title, errors) do
Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc ->
append_output_entry(acc, title, inspect(reason), slug, "error")
end)
end
defp handle_file_picker_result(socket, {:ok, _media}), defp handle_file_picker_result(socket, {:ok, _media}),
do: refresh_content(socket, socket.assigns.workbench) do: refresh_content(socket, socket.assigns.workbench)
@@ -914,6 +1100,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 +1347,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
@@ -70,6 +104,23 @@ defmodule BDS.Desktop.ShellLive.Bridges do
{:noreply, callbacks.refresh_content.(socket, socket.assigns.workbench)} {:noreply, callbacks.refresh_content.(socket, socket.assigns.workbench)}
end end
def handle_info({:confirm_tag_delete, tag_id, _tag_name, post_count}, socket, _callbacks) do
page_language = socket.assigns.page_language
suffix = if post_count == 1, do: "", else: "s"
message = "This tag is used in #{post_count} post#{suffix}. Delete anyway?"
overlay = %{
kind: :confirm_dialog,
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
message: message,
tag_id: tag_id,
confirm_action: :delete_tag
}
{:noreply, assign(socket, :shell_overlay, overlay)}
end
def handle_info(:settings_changed, socket, callbacks) do def handle_info(:settings_changed, socket, callbacks) do
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)} {:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
end end
@@ -116,6 +167,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
@@ -84,10 +90,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end end
def handle_event("send_chat_editor_message", _params, socket) do def handle_event("send_chat_editor_message", _params, socket) do
Logger.info("CHAT send_chat_editor_message called, input=#{inspect(socket.assigns.input)}")
{:noreply, do_send_message(socket)} {:noreply, do_send_message(socket)}
end end
def handle_event("abort_chat_editor_message", _params, socket) do def handle_event("abort_chat_editor_message", _params, socket) do
Logger.info("CHAT abort_chat_editor_message called")
{:noreply, do_abort_message(socket)} {:noreply, do_abort_message(socket)}
end end
@@ -97,7 +105,9 @@ 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 +121,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 +131,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 +160,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
} }
@@ -204,8 +231,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
build_data(socket) build_data(socket)
socket.assigns.offline_mode -> socket.assigns.offline_mode ->
Notify.output(dgettext("ui", "Chat"), Notify.output(
dgettext("ui", "Automatic AI actions stay gated by airplane mode."), "info") dgettext("ui", "Chat"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
"info"
)
build_data(socket) build_data(socket)
@@ -656,6 +686,102 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<h3><%= @surface.title %></h3> <h3><%= @surface.title %></h3>
<% end %> <% end %>
<p class="chat-surface-chart-type"><%= @surface.chart_type %></p> <p class="chat-surface-chart-type"><%= @surface.chart_type %></p>
<%= case @surface.chart_type do %>
<% chart_type when chart_type in ["pie", "donut"] -> %>
<% pie = BDS.Desktop.ShellLive.ChatEditor.ChartView.pie(@surface.series) %>
<div class="chat-surface-chart-pie">
<svg class="chat-surface-chart-pie-svg" viewBox={"0 0 #{pie.size} #{pie.size}"} preserveAspectRatio="xMidYMid meet">
<%= for slice <- pie.slices do %>
<%= if slice.full_circle do %>
<circle class="chat-surface-chart-pie-slice" cx={pie.center} cy={pie.center} r={pie.radius} fill={slice.color}>
<title><%= slice.label %>: <%= slice.value %></title>
</circle>
<% else %>
<path class="chat-surface-chart-pie-slice" d={slice.d} fill={slice.color}>
<title><%= slice.label %>: <%= slice.value %></title>
</path>
<% end %>
<% end %>
<%= if @surface.chart_type == "donut" do %>
<circle class="chat-surface-chart-donut-hole" cx={pie.center} cy={pie.center} r={pie.donut_inner} />
<text class="chat-surface-chart-donut-total" x={pie.center} y={pie.center} text-anchor="middle" dominant-baseline="central"><%= pie.total %></text>
<% end %>
</svg>
<div class="chat-surface-chart-legend">
<%= for item <- pie.legend do %>
<span class="chat-surface-chart-legend-item">
<span class="chat-surface-chart-legend-swatch" style={"background-color: #{item.color}"}></span>
<%= item.label %>
</span>
<% end %>
</div>
</div>
<% chart_type when chart_type in ["line", "area"] -> %>
<% line = BDS.Desktop.ShellLive.ChatEditor.ChartView.line(@surface.series, @surface.chart_type == "area") %>
<svg class="chat-surface-chart-line-svg" viewBox={line.view_box} preserveAspectRatio="xMidYMid meet">
<%= for tick <- line.grid do %>
<line class="chat-surface-chart-line-grid" x1={tick.x1} y1={tick.y} x2={tick.x2} y2={tick.y} />
<text class="chat-surface-chart-line-y-label" x={tick.label_x} y={tick.y} text-anchor="end" dominant-baseline="middle"><%= tick.label %></text>
<% end %>
<%= if line.area? do %>
<polygon class="chat-surface-chart-area-fill" points={line.area_points} />
<% end %>
<polyline class="chat-surface-chart-line-path" points={line.polyline} fill="none" />
<%= for dot <- line.dots do %>
<circle class="chat-surface-chart-line-dot" cx={dot.x} cy={dot.y} r="3">
<title><%= dot.label %>: <%= dot.value %></title>
</circle>
<% end %>
<%= for label <- line.x_labels do %>
<text class="chat-surface-chart-line-x-label" x={label.x} y={label.y} text-anchor="middle"><%= label.label %></text>
<% end %>
</svg>
<% "heatmap" -> %>
<% heat = BDS.Desktop.ShellLive.ChatEditor.ChartView.heatmap(@surface.series) %>
<%= if heat.rows != [] do %>
<div class="chat-surface-chart-heatmap" style={"grid-template-columns: auto repeat(#{heat.column_count}, 1fr)"}>
<span class="chat-surface-chart-heatmap-corner"></span>
<%= for col <- heat.columns do %>
<span class="chat-surface-chart-heatmap-col-label"><%= col %></span>
<% end %>
<%= for row <- heat.rows do %>
<span class="chat-surface-chart-heatmap-row-label"><%= row.label %></span>
<%= for cell <- row.cells do %>
<span class="chat-surface-chart-heatmap-cell" style={"background: #{cell.bg}; color: #{cell.fg}"} title={cell.value}><%= if cell.value > 0, do: cell.value %></span>
<% end %>
<% end %>
</div>
<% end %>
<% "stacked-bar" -> %>
<% stacked = BDS.Desktop.ShellLive.ChatEditor.ChartView.stacked(@surface.series) %>
<div class="chat-surface-chart-list">
<%= for row <- stacked.rows do %>
<div class="chat-surface-chart-row">
<div class="chat-surface-chart-meta">
<span><%= row.label %></span>
<span><%= row.total %></span>
</div>
<div class="chat-surface-chart-bar chat-surface-chart-bar-stacked">
<%= for seg <- row.segments do %>
<span class="chat-surface-chart-bar-segment" style={"width: #{seg.width}%; background-color: #{seg.color}"} title={"#{seg.label}: #{seg.value}"}></span>
<% end %>
</div>
</div>
<% end %>
</div>
<div class="chat-surface-chart-legend">
<%= for item <- stacked.legend do %>
<span class="chat-surface-chart-legend-item">
<span class="chat-surface-chart-legend-swatch" style={"background-color: #{item.color}"}></span>
<%= item.label %>
</span>
<% end %>
</div>
<% _bar -> %>
<div class="chat-surface-chart-list"> <div class="chat-surface-chart-list">
<%= for series <- @surface.series do %> <%= for series <- @surface.series do %>
<div class="chat-surface-chart-row"> <div class="chat-surface-chart-row">
@@ -669,6 +795,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
</div> </div>
<% end %> <% end %>
</div> </div>
<% end %>
<% "metric" -> %> <% "metric" -> %>
<div class="chat-surface-metric"> <div class="chat-surface-metric">
@@ -819,6 +946,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,284 @@
defmodule BDS.Desktop.ShellLive.ChatEditor.ChartView do
@moduledoc """
Pure geometry/view helpers for rendering a2ui chart surfaces.
Mirrors the behaviour of the legacy bDS `A2UIChart` React component so the
Elixir rewrite supports the same chart types (`bar`, `stacked-bar`, `line`,
`area`, `pie`, `donut`, `heatmap`) with the same visual output. Each function
returns plain maps/lists that a HEEx template can iterate over; no markup is
produced here so the geometry stays unit-testable.
Series entries are expected to be maps with `:label`, `:value` and an optional
list of `:segments` (each a map with `:label` and `:value`).
"""
# Matches the legacy bDS SEGMENT_COLORS palette.
@palette ["#75beff", "#89d185", "#d18616", "#f14c4c", "#b180d7", "#e2e210"]
# Line/area chart geometry (viewBox units), matching the legacy component.
@line_width 300
@line_height 140
@pad_top 8
@pad_right 12
@pad_bottom 24
@pad_left 40
@doc "Returns the colour for the series/segment at `index` (cycles the palette)."
@spec color(integer()) :: String.t()
def color(index), do: Enum.at(@palette, rem(index, length(@palette)))
@doc """
Pie/donut geometry: SVG slices, a legend and the running total.
"""
@spec pie([map()]) :: map()
def pie(series) do
total = series |> Enum.map(& &1.value) |> Enum.sum()
center = 70
radius = 56
{slices, _angle} =
if total > 0 do
series
|> Enum.with_index()
|> Enum.map_reduce(0.0, fn {entry, i}, current ->
slice_angle = entry.value / total * 360
end_angle = current + slice_angle
slice =
if slice_angle >= 359.99 do
%{full_circle: true, d: nil, color: color(i), label: entry.label, value: entry.value}
else
%{
full_circle: false,
d: describe_slice(center, radius, current, end_angle),
color: color(i),
label: entry.label,
value: entry.value
}
end
{slice, end_angle}
end)
else
{[], 0.0}
end
%{
size: 140,
center: center,
radius: radius,
donut_inner: radius * 0.58,
total: total,
slices: slices,
legend: legend(series)
}
end
@doc """
Line/area geometry: gridlines + Y labels, the polyline points, an optional
area polygon, the data dots and the X-axis labels.
"""
@spec line([map()], boolean()) :: map()
def line(series, area?) do
plot_width = @line_width - @pad_left - @pad_right
plot_height = @line_height - @pad_top - @pad_bottom
max_value = Enum.max([0 | Enum.map(series, & &1.value)])
ticks = y_ticks(max_value)
y_max = List.last(ticks)
len = length(series)
x_step = if len > 1, do: plot_width / (len - 1), else: 0.0
points =
series
|> Enum.with_index()
|> Enum.map(fn {entry, i} ->
x = @pad_left + if(len > 1, do: i * x_step, else: plot_width / 2)
y = @pad_top + if(y_max > 0, do: (1 - entry.value / y_max) * plot_height, else: plot_height)
%{x: x, y: y, label: entry.label, value: entry.value}
end)
polyline = Enum.map_join(points, " ", fn p -> "#{fmt(p.x)},#{fmt(p.y)}" end)
baseline = @pad_top + plot_height
area_points =
case {area?, points} do
{true, [_ | _]} ->
first = List.first(points)
last = List.last(points)
"#{fmt(first.x)},#{fmt(baseline)} #{polyline} #{fmt(last.x)},#{fmt(baseline)}"
_ ->
""
end
grid =
Enum.map(ticks, fn tick ->
y = @pad_top + if(y_max > 0, do: (1 - tick / y_max) * plot_height, else: plot_height)
%{
y: fmt(y),
x1: fmt(@pad_left),
x2: fmt(@pad_left + plot_width),
label: fmt(tick),
label_x: fmt(@pad_left - 4)
}
end)
%{
view_box: "0 0 #{@line_width} #{@line_height}",
area?: area?,
area_points: area_points,
polyline: polyline,
grid: grid,
dots: Enum.map(points, fn p -> %{x: fmt(p.x), y: fmt(p.y), label: p.label, value: p.value} end),
x_labels:
Enum.map(points, fn p -> %{x: fmt(p.x), y: fmt(@line_height - 4), label: p.label} end)
}
end
@doc """
Heatmap grid: column labels plus rows of colour-scaled cells. Each series
entry is a row and each of its segments is a column.
"""
@spec heatmap([map()]) :: map()
def heatmap(series) do
rows_src = Enum.filter(series, &(&1.segments != []))
columns = rows_src |> Enum.flat_map(fn e -> Enum.map(e.segments, & &1.label) end) |> Enum.uniq()
global_max =
case Enum.flat_map(rows_src, fn e -> Enum.map(e.segments, & &1.value) end) do
[] -> 0
values -> Enum.max(values)
end
rows =
Enum.map(rows_src, fn entry ->
seg_map = Map.new(entry.segments, fn s -> {s.label, s.value} end)
cells =
Enum.map(columns, fn col ->
value = Map.get(seg_map, col, 0)
alpha = if global_max > 0, do: value / global_max, else: 0.0
{bg, fg} = cell_colors(alpha)
%{value: value, bg: bg, fg: fg}
end)
%{label: entry.label, cells: cells}
end)
%{columns: columns, column_count: length(columns), rows: rows}
end
@doc """
Stacked-bar geometry: each row's segments with widths and colours, plus a
legend of the unique segment labels.
"""
@spec stacked([map()]) :: map()
def stacked(series) do
seg_labels =
series |> Enum.flat_map(fn e -> Enum.map(e.segments, & &1.label) end) |> Enum.uniq()
totals =
Enum.map(series, fn e ->
if e.segments == [], do: e.value, else: Enum.sum(Enum.map(e.segments, & &1.value))
end)
max_total = Enum.max([0 | totals])
rows =
series
|> Enum.zip(totals)
|> Enum.map(fn {entry, total} ->
segments =
Enum.map(entry.segments, fn s ->
width = if max_total > 0, do: s.value / max_total * 100, else: 0.0
%{
label: s.label,
value: s.value,
width: fmt(width),
color: color(Enum.find_index(seg_labels, &(&1 == s.label)) || 0)
}
end)
%{label: entry.label, total: total, segments: segments}
end)
legend = seg_labels |> Enum.with_index() |> Enum.map(fn {l, i} -> %{label: l, color: color(i)} end)
%{rows: rows, legend: legend, max_total: max_total}
end
@doc """
Nice, rounded Y-axis tick values for `max_value`. Always starts at 0 and ends
at or above `max_value`.
"""
@spec y_ticks(number(), pos_integer()) :: [number()]
def y_ticks(max_value, tick_count \\ 4)
def y_ticks(max_value, _tick_count) when max_value <= 0, do: [0]
def y_ticks(max_value, tick_count) do
raw_step = max_value / (tick_count - 1)
magnitude = :math.pow(10, Float.floor(:math.log10(raw_step)))
normalised = raw_step / magnitude
nice_step =
cond do
normalised <= 1 -> magnitude
normalised <= 2 -> 2 * magnitude
normalised <= 5 -> 5 * magnitude
true -> 10 * magnitude
end
ticks =
0.0
|> Stream.iterate(&(&1 + nice_step))
|> Enum.take_while(&(&1 <= max_value + nice_step * 0.01))
|> Enum.map(&Float.round(&1, 6))
if length(ticks) < 2, do: ticks ++ [Float.round(nice_step, 6)], else: ticks
end
# ── private helpers ──────────────────────────────────────────────────
defp legend(series) do
series |> Enum.with_index() |> Enum.map(fn {e, i} -> %{label: e.label, color: color(i)} end)
end
defp describe_slice(center, radius, start_angle, end_angle) do
start_rad = (start_angle - 90) * :math.pi() / 180
end_rad = (end_angle - 90) * :math.pi() / 180
x1 = center + radius * :math.cos(start_rad)
y1 = center + radius * :math.sin(start_rad)
x2 = center + radius * :math.cos(end_rad)
y2 = center + radius * :math.sin(end_rad)
large_arc = if end_angle - start_angle > 180, do: 1, else: 0
"M#{fmt(center)},#{fmt(center)} L#{fmt(x1)},#{fmt(y1)} " <>
"A#{fmt(radius)},#{fmt(radius)} 0 #{large_arc} 1 #{fmt(x2)},#{fmt(y2)} Z"
end
defp cell_colors(alpha) when alpha <= 0, do: {"transparent", "inherit"}
defp cell_colors(alpha) do
{r, g, b} = lerp_rgb({53, 117, 56}, {183, 72, 72}, alpha)
opacity = 0.25 + alpha * 0.75
bg = "rgba(#{r},#{g},#{b},#{Float.round(opacity, 2)})"
er = round(r * opacity + 30 * (1 - opacity))
eg = round(g * opacity + 30 * (1 - opacity))
eb = round(b * opacity + 30 * (1 - opacity))
fg = if 0.299 * er + 0.587 * eg + 0.114 * eb > 140, do: "#000", else: "#fff"
{bg, fg}
end
defp lerp_rgb({ar, ag, ab}, {br, bg, bb}, t) do
{round(ar + (br - ar) * t), round(ag + (bg - ag) * t), round(ab + (bb - ab) * t)}
end
defp fmt(n) when is_integer(n), do: Integer.to_string(n)
defp fmt(n) when is_float(n) do
rounded = Float.round(n, 2)
if rounded == Float.round(rounded, 0), do: Integer.to_string(trunc(rounded)), else: :erlang.float_to_binary(rounded, [:short])
end
end

View File

@@ -87,7 +87,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
%{ %{
label: map_value(entry, "label", dgettext("ui", "Assistant")), label: map_value(entry, "label", dgettext("ui", "Assistant")),
value: numeric_value(map_value(entry, "value", 0)), value: numeric_value(map_value(entry, "value", 0)),
segments: List.wrap(map_value(entry, "segments", [])) segments:
entry
|> map_value("segments", [])
|> List.wrap()
|> Enum.map(fn segment ->
%{
label: map_value(segment, "label", ""),
value: numeric_value(map_value(segment, "value", 0))
}
end)
} }
end) end)
@@ -95,7 +104,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
id: surface_id, id: surface_id,
type: "chart", type: "chart",
title: map_value(arguments, "title"), title: map_value(arguments, "title"),
chart_type: map_value(arguments, "chart_type", "bar"), chart_type: map_value(arguments, "chartType") || map_value(arguments, "chart_type", "bar"),
series: series, series: series,
max_value: Enum.max([0 | Enum.map(series, & &1.value)]) max_value: Enum.max([0 | Enum.map(series, & &1.value)])
} }

View File

@@ -154,7 +154,7 @@
<form class="chat-input-wrapper flex items-end gap-2" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}> <form class="chat-input-wrapper flex items-end gap-2" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
<textarea class="chat-input chat-surface-input ui-textarea" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea> <textarea class="chat-input chat-surface-input ui-textarea" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
<button class="chat-send-button ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button> <button class="chat-send-button ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself}>↑</button>
</form> </form>
<%= if @chat_editor.action_error do %> <%= if @chat_editor.action_error do %>

View File

@@ -0,0 +1,110 @@
defmodule BDS.Desktop.ShellLive.EditorImageDrop do
@moduledoc false
# Implements the drag-and-drop image chain described in
# action_patterns.allium DragDropImageChain (trigger: editor_post.allium
# PostDragDropImage). A single image file dropped on the post editor body
# runs four synchronous steps the user waits on, then two background steps
# whose results are auto-applied without a modal.
require Logger
alias BDS.{AI, Media, Metadata, Posts}
@doc """
Synchronous portion of the chain (steps 1-4):
1. importMedia(file) -> media record + file copy + base sidecar
2. generateThumbnails(media) -> small/medium/large/ai (done inside import_media)
3. linkMediaToPost(media, post) -> update sidecar linkedPostIds
4. caller inserts the returned markdown at the cursor
Returns `{:ok, media, markdown}` where `markdown` is the reference inserted at
the cursor. These steps are not AI activities, so they run regardless of
airplane mode.
"""
@spec import_and_link(String.t(), String.t(), String.t()) ::
{:ok, Media.Media.t(), String.t()} | {:error, term()}
def import_and_link(project_id, post_id, source_path) do
with {:ok, media} <-
Media.import_media(%{project_id: project_id, source_path: source_path}),
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
{:ok, media, markdown_for(media)}
end
end
@doc """
Markdown reference inserted at the cursor (step 4): `![](bds-media://id)` for
images, a plain link for other file types.
"""
@spec markdown_for(Media.Media.t()) :: String.t()
def markdown_for(media) do
if String.starts_with?(media.mime_type || "", "image/") do
"![](bds-media://#{media.id})"
else
"[#{media.original_name}](bds-media://#{media.id})"
end
end
@doc """
Background portion of the chain (steps 5-6), gated behind airplane mode:
5. aiImageAnalysis(media) -> results auto-applied to media metadata (no modal)
6. if auto-translate enabled (post.do_not_translate == false):
translateMediaMetadata(media, lang) for each blog language
Only runs for images. Failures are logged and never roll back the import.
"""
@spec enrich(Media.Media.t(), String.t(), String.t()) :: :ok
def enrich(media, post_id, language) do
if image?(media) do
with {: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
}) do
maybe_translate(media.id, post_id, language)
else
{:error, reason} ->
Logger.warning("Drag-drop AI analysis failed for #{media.id}: #{inspect(reason)}")
end
end
:ok
end
defp maybe_translate(media_id, post_id, language) do
post = Posts.get_post(post_id)
if post && not post.do_not_translate do
translate_targets(post.project_id, language)
|> Enum.each(fn target ->
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
})
{:error, reason} ->
Logger.warning(
"Drag-drop media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
)
end
end)
end
end
defp translate_targets(project_id, language) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
[metadata.main_language | metadata.blog_languages || []]
|> Enum.reject(&(&1 == language or is_nil(&1)))
|> Enum.uniq()
end
defp image?(media), do: String.starts_with?(media.mime_type || "", "image/")
end

View File

@@ -0,0 +1,214 @@
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

@@ -642,7 +642,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
defp maybe_update_tab_meta(socket, name) do defp maybe_update_tab_meta(socket, name) do
title = name || dgettext("ui", "Untitled Import") title = name || dgettext("ui", "Untitled Import")
Notify.tab_meta(:import, socket.assigns.definition_id, title, Notify.tab_meta(
:import,
socket.assigns.definition_id,
title,
dgettext( dgettext(
"ui", "ui",
"Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported." "Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported."

View File

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

View File

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

View File

@@ -568,7 +568,7 @@
</div> </div>
<footer class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2" data-region="status-bar" data-testid="status-bar"> <footer class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2" data-region="status-bar" data-testid="status-bar">
<div class="status-bar-left flex min-w-0 items-center gap-2 overflow-hidden"> <div class="status-bar-left flex min-w-0 items-center gap-2">
<%= if @is_mac_ui do %> <%= if @is_mac_ui do %>
<div class="status-shell-controls flex items-center gap-1" data-testid="status-shell-controls"> <div class="status-shell-controls flex items-center gap-1" data-testid="status-shell-controls">
<button <button
@@ -659,7 +659,7 @@
</div> </div>
<% end %> <% end %>
</div> </div>
<button class="status-bar-item status-bar-task-button inline-flex items-center gap-2" data-testid="status-task-button" type="button" phx-click="open_tasks_panel"> <button class="status-bar-item status-bar-task-button inline-flex min-w-0 items-center gap-2" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
<%= if @status.left.running_task_message do %> <%= if @status.left.running_task_message do %>
<span class="task-spinner"></span> <span class="task-spinner"></span>
<% end %> <% end %>

View File

@@ -126,8 +126,12 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
Notify.dirty(:media, media.id, false) Notify.dirty(:media, media.id, false)
Notify.tab_meta(:media, media.id, display_title(updated_media), Notify.tab_meta(
updated_media.original_name || updated_media.mime_type || "") :media,
media.id,
display_title(updated_media),
updated_media.original_name || updated_media.mime_type || ""
)
{:noreply, socket} {:noreply, socket}
@@ -484,8 +488,12 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
Notify.dirty(:media, media.id, false) Notify.dirty(:media, media.id, false)
Notify.tab_meta(:media, media.id, display_title(updated_media), Notify.tab_meta(
updated_media.original_name || updated_media.mime_type || "") :media,
media.id,
display_title(updated_media),
updated_media.original_name || updated_media.mime_type || ""
)
notify_output(socket, dgettext("ui", "Media"), dgettext("ui", "Media saved")) notify_output(socket, dgettext("ui", "Media"), dgettext("ui", "Media saved"))
socket socket

View File

@@ -180,6 +180,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end end
@spec move_selected(term(), term()) :: term() @spec move_selected(term(), term()) :: term()
def move_selected(%{selected_id: @home_item_id} = state, _direction), do: state
def move_selected(%{selected_id: selected_id} = state, direction) def move_selected(%{selected_id: selected_id} = state, direction)
when direction in [:up, :down] do when direction in [:up, :down] do
case find_path(state.items, selected_id) do case find_path(state.items, selected_id) do
@@ -209,6 +211,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end end
@spec indent_selected(term()) :: term() @spec indent_selected(term()) :: term()
def indent_selected(%{selected_id: @home_item_id} = state), do: state
def indent_selected(%{selected_id: selected_id} = state) do def indent_selected(%{selected_id: selected_id} = state) do
case find_path(state.items, selected_id) do case find_path(state.items, selected_id) do
nil -> nil ->
@@ -249,6 +253,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end end
@spec unindent_selected(term()) :: term() @spec unindent_selected(term()) :: term()
def unindent_selected(%{selected_id: @home_item_id} = state), do: state
def unindent_selected(%{selected_id: selected_id} = state) do def unindent_selected(%{selected_id: selected_id} = state) do
case find_path(state.items, selected_id) do case find_path(state.items, selected_id) do
nil -> nil ->
@@ -295,6 +301,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
when drag_item_id == target_item_id, when drag_item_id == target_item_id,
do: state do: state
def drop_selected(state, @home_item_id, _target_item_id, _position), do: state
@spec drop_selected(term(), term(), term(), term()) :: term() @spec drop_selected(term(), term(), term(), term()) :: term()
def drop_selected(state, drag_item_id, target_item_id, position) do def drop_selected(state, drag_item_id, target_item_id, position) do
drag_path = find_path(state.items, drag_item_id) drag_path = find_path(state.items, drag_item_id)

View File

@@ -5,6 +5,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
@spec can_move_up?(term(), term()) :: term() @spec can_move_up?(term(), term()) :: term()
def can_move_up?(items, selected_id) do def can_move_up?(items, selected_id) do
if selected_id == TreeOps.home_item_id() do
false
else
case TreeOps.find_path(items, selected_id) do case TreeOps.find_path(items, selected_id) do
[_parent, index] -> index > 0 [_parent, index] -> index > 0
[index] -> index > 0 [index] -> index > 0
@@ -12,9 +15,13 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
_other -> false _other -> false
end end
end end
end
@spec can_move_down?(term(), term()) :: term() @spec can_move_down?(term(), term()) :: term()
def can_move_down?(items, selected_id) do def can_move_down?(items, selected_id) do
if selected_id == TreeOps.home_item_id() do
false
else
case TreeOps.find_path(items, selected_id) do case TreeOps.find_path(items, selected_id) do
nil -> nil ->
false false
@@ -25,9 +32,13 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
index < length(TreeOps.items_at_path(items, parent_path)) - 1 index < length(TreeOps.items_at_path(items, parent_path)) - 1
end end
end end
end
@spec can_indent?(term(), term()) :: term() @spec can_indent?(term(), term()) :: term()
def can_indent?(items, selected_id) do def can_indent?(items, selected_id) do
if selected_id == TreeOps.home_item_id() do
false
else
case TreeOps.find_path(items, selected_id) do case TreeOps.find_path(items, selected_id) do
nil -> nil ->
false false
@@ -49,15 +60,20 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
) )
end end
end end
end
@spec can_unindent?(term(), term()) :: term() @spec can_unindent?(term(), term()) :: term()
def can_unindent?(items, selected_id) do def can_unindent?(items, selected_id) do
if selected_id == TreeOps.home_item_id() do
false
else
case TreeOps.find_path(items, selected_id) do case TreeOps.find_path(items, selected_id) do
[_index] -> false [_index] -> false
path when is_list(path) -> length(path) > 1 path when is_list(path) -> length(path) > 1
_other -> false _other -> false
end end
end end
end
@spec can_delete?(term()) :: term() @spec can_delete?(term()) :: term()
def can_delete?(selected_id), def can_delete?(selected_id),

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

@@ -6,7 +6,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
import Phoenix.Component, only: [assign: 3] import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [send_update: 2] import Phoenix.LiveView, only: [send_update: 2]
alias BDS.{AI, Media, Metadata} alias BDS.{AI, Media, Metadata, Tags}
alias BDS.Desktop.{Overlay} alias BDS.Desktop.{Overlay}
alias BDS.Desktop.ShellLive.{ alias BDS.Desktop.ShellLive.{
@@ -286,6 +286,25 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
{%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} -> {%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} ->
close_overlay_with_output(socket, callbacks.append_output, title, entity_name) close_overlay_with_output(socket, callbacks.append_output, title, entity_name)
{%{kind: :confirm_dialog, confirm_action: :delete_tag, tag_id: tag_id}, _tab} ->
case Tags.delete_tag(tag_id) do
{:ok, :deleted} ->
socket
|> assign(:shell_overlay, nil)
|> callbacks.reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> assign(:shell_overlay, nil)
|> callbacks.append_output.(
dgettext("ui", "Delete Tag"),
inspect(reason),
nil,
"error"
)
|> callbacks.reload.(socket.assigns.workbench)
end
{%{kind: :confirm_dialog, title: title, message: message}, _tab} -> {%{kind: :confirm_dialog, title: title, message: message}, _tab} ->
close_overlay_with_output(socket, callbacks.append_output, title, message) close_overlay_with_output(socket, callbacks.append_output, title, message)

View File

@@ -3,9 +3,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
use Phoenix.LiveComponent use Phoenix.LiveComponent
alias BDS.{AI, Posts, Preview} alias BDS.{AI, Metadata, Posts, Preview}
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.Notify alias BDS.Desktop.ShellLive.{EditorImageDrop, Notify}
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata} alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata}
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Tags alias BDS.Tags
@@ -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,19 @@ 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("editor_image_dropped", %{"path" => path}, socket)
when is_binary(path) do
{:noreply, do_image_drop(socket, path)}
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 +387,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),
@@ -457,10 +476,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|> assign(:dirty?, false) |> assign(:dirty?, false)
|> build_data() |> build_data()
Notify.tab_meta(:post, post.id, record_title(record, refreshed_post), Notify.tab_meta(
Atom.to_string(record_status(record))) :post,
post.id,
record_title(record, refreshed_post),
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
@@ -496,8 +520,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|> assign(:dirty?, false) |> assign(:dirty?, false)
|> build_data() |> build_data()
Notify.tab_meta(:post, post.id, record_title(record, refreshed_post), Notify.tab_meta(
Atom.to_string(record_status(record))) :post,
post.id,
record_title(record, refreshed_post),
Atom.to_string(record_status(record))
)
Notify.dirty(:post, post.id, false) Notify.dirty(:post, post.id, false)
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post published")) notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post published"))
@@ -531,9 +559,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|> assign(:dirty?, false) |> assign(:dirty?, false)
|> build_data() |> build_data()
Notify.tab_meta(:post, post.id, Notify.tab_meta(
:post,
post.id,
restored_post.title || restored_post.slug || restored_post.id, restored_post.title || restored_post.slug || restored_post.id,
Atom.to_string(restored_post.status || :draft)) Atom.to_string(restored_post.status || :draft)
)
Notify.dirty(:post, post.id, false) Notify.dirty(:post, post.id, false)
socket socket
@@ -559,6 +590,122 @@ 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
# Drag-and-drop image chain (action_patterns.allium DragDropImageChain).
# Steps 1-4 run synchronously while the user waits; steps 5-6 (AI analysis +
# auto-translate) run in the background and are gated behind airplane mode.
defp do_image_drop(socket, path) do
case socket.assigns.post do
%Post{} = post ->
case EditorImageDrop.import_and_link(post.project_id, post.id, path) do
{:ok, media, markdown} ->
maybe_enrich_dropped_image(media, post)
socket
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{
id: socket.assigns.post_id,
content: markdown
})
|> notify_output(
dgettext("ui", "Insert Image"),
dgettext("ui", "Added %{name}", name: media.original_name)
)
{:error, reason} ->
notify_output(
socket,
dgettext("ui", "Insert Image"),
dgettext("ui", "Failed to import %{path}: %{reason}",
path: Path.basename(path),
reason: inspect(reason)
),
"error"
)
end
_other ->
socket
end
end
defp maybe_enrich_dropped_image(media, post) do
unless AI.airplane_mode?() do
{:ok, metadata} = Metadata.get_project_metadata(post.project_id)
language = metadata.main_language || "en"
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
EditorImageDrop.enrich(media, post.id, language)
end)
end
:ok
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 %>
@@ -392,11 +436,14 @@
class="post-editor-markdown-surface monaco-editor-shell" class="post-editor-markdown-surface monaco-editor-shell"
data-testid="post-editor-markdown-surface" data-testid="post-editor-markdown-surface"
phx-hook="MonacoEditor" phx-hook="MonacoEditor"
phx-target={@myself}
data-monaco-editor-id={@post_editor.id} data-monaco-editor-id={@post_editor.id}
data-monaco-input-id={"post-editor-content-#{@post_editor.id}"} data-monaco-input-id={"post-editor-content-#{@post_editor.id}"}
data-monaco-language="markdown-with-macros" data-monaco-language="markdown-with-macros"
data-monaco-word-wrap="on" data-monaco-word-wrap="on"
data-monaco-insert-event="post-editor-insert-content" data-monaco-insert-event="post-editor-insert-content"
data-monaco-drop-event="editor_image_dropped"
data-monaco-drop-post-id={@post_editor.id}
> >
<div id={"post-editor-monaco-#{@post_editor.id}"} class="monaco-editor-instance min-h-0 flex-1" phx-update="ignore"></div> <div id={"post-editor-monaco-#{@post_editor.id}"} class="monaco-editor-instance min-h-0 flex-1" phx-update="ignore"></div>
<textarea id={"post-editor-content-#{@post_editor.id}"} class="monaco-editor-input post-editor-content" data-testid="post-editor-content" data-post-editor-id={@post_editor.id} name="post_editor[content]" rows="18" spellcheck="false"><%= @post_editor.form["content"] %></textarea> <textarea id={"post-editor-content-#{@post_editor.id}"} class="monaco-editor-input post-editor-content" data-testid="post-editor-content" data-post-editor-id={@post_editor.id} name="post_editor[content]" rows="18" spellcheck="false"><%= @post_editor.form["content"] %></textarea>

View File

@@ -9,8 +9,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
@spec ai_form(term()) :: term() @spec ai_form(term()) :: term()
def ai_form(assigns) do def ai_form(assigns) do
{:ok, online_endpoint} = AI.get_endpoint(:online) online_endpoint = safe_endpoint(:online)
{:ok, airplane_endpoint} = AI.get_endpoint(:airplane) airplane_endpoint = safe_endpoint(:airplane)
%{ %{
"online_url" => Map.get(online_endpoint || %{}, :url, ""), "online_url" => Map.get(online_endpoint || %{}, :url, ""),
@@ -168,6 +168,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
end end
end end
defp safe_endpoint(kind) do
case AI.get_endpoint(kind) do
{:ok, ep} -> ep
_error -> nil
end
end
defp ai_attrs(assigns) do defp ai_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_ai_draft, %{}) draft = Map.get(assigns, :settings_editor_ai_draft, %{})

View File

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

View File

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

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

View File

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

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
@@ -114,29 +144,18 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
def handle_event("delete_tag_editor", _params, socket) do def handle_event("delete_tag_editor", _params, socket) do
case socket.assigns.tags_editor.selected do case socket.assigns.tags_editor.selected do
[tag_name] -> [tag_name] ->
case Repo.get_by(Tag, project_id = socket.assigns.project_id
project_id: socket.assigns.project_id,
name: tag_name case Repo.get_by(Tag, project_id: project_id, name: tag_name) do
) do
nil -> nil ->
{:noreply, socket} {:noreply, socket}
%Tag{} = tag -> %Tag{id: tag_id} ->
case Tags.delete_tag(tag.id) do counts = tag_counts(project_id)
{:ok, _deleted} -> post_count = Map.get(counts, tag_name, 0)
notify_parent(:tags_changed) Notify.parent({:confirm_tag_delete, tag_id, tag_name, post_count})
socket
|> put_in_tags_editor([:selected], [])
|> put_in_tags_editor([:edit_draft], %{})
|> load_data()
|> noreply()
{:error, reason} ->
notify_output(dgettext("ui", "Tags"), inspect(reason), "error")
{:noreply, socket} {:noreply, socket}
end end
end
_other -> _other ->
{:noreply, socket} {:noreply, socket}
@@ -241,6 +260,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 +348,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

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

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
@@ -15,6 +16,7 @@ defmodule BDS.Embeddings do
@duplicate_threshold 0.92 @duplicate_threshold 0.92
@exact_match_score 0.999999 @exact_match_score 0.999999
@key_batch_size 199
def model_id, do: configured_backend().model_info().model_id def model_id, do: configured_backend().model_info().model_id
def dimensions, do: configured_backend().model_info().dimensions def dimensions, do: configured_backend().model_info().dimensions
@@ -73,9 +75,17 @@ defmodule BDS.Embeddings do
order_by: [asc: post.created_at, asc: post.slug] order_by: [asc: post.created_at, asc: post.slug]
) )
Enum.each(posts, &sync_post_if_enabled(&1, refresh_index: false)) existing_keys = preload_keys_by_post_id(project_id, Enum.map(posts, & &1.id))
case build_key_rows(posts, existing_keys, max_label_value(), nil, false) do
{:ok, 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
@@ -95,25 +105,26 @@ 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,
where: key.project_id == ^project_id and key.post_id not in ^post_ids where: key.project_id == ^project_id and key.post_id not in ^post_ids
) )
posts existing_keys = preload_keys_by_post_id(project_id)
|> Enum.with_index(1)
|> Enum.each(fn {post, index} ->
sync_post_if_enabled(post, refresh_index: false)
:ok = report_rebuild_progress(on_progress, index, total_posts, "embedding entries")
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)
: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
@@ -167,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(%{
@@ -184,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()
@@ -192,9 +202,150 @@ defmodule BDS.Embeddings do
:ok = rebuild_snapshot(post.project_id) :ok = rebuild_snapshot(post.project_id)
end end
: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 :ok
end end
end end
end
defp preload_keys_by_post_id(project_id) do
Repo.all(from key in Key, where: key.project_id == ^project_id)
|> Map.new(&{&1.post_id, &1})
end
defp preload_keys_by_post_id(project_id, post_ids) do
Repo.all(
from key in Key,
where: key.project_id == ^project_id and key.post_id in ^post_ids
)
|> Map.new(&{&1.post_id, &1})
end
defp max_label_value do
Repo.one(from key in Key, select: max(key.label)) || 0
end
# Builds the upsert rows for a batch of posts. Unless `force?` is set, posts
# whose content_hash is unchanged are skipped (ContentHashSkipsUnchanged); the
# 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)
%{
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
{acc, next_label}
end
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
defp batch_upsert_keys([]), do: :ok
defp batch_upsert_keys(rows) do
rows
|> Enum.chunk_every(@key_batch_size)
|> Enum.each(fn chunk ->
placeholders = Enum.map_join(chunk, ", ", fn _ -> "(?, ?, ?, ?, ?)" end)
params = List.flatten(chunk)
Repo.query!(
"INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector) VALUES #{placeholders} ON CONFLICT(label) DO UPDATE SET content_hash = excluded.content_hash, vector = excluded.vector",
params
)
end)
end
def remove_post(post_id) when is_binary(post_id) do def remove_post(post_id) when is_binary(post_id) do
project_id = project_id =
@@ -227,29 +378,21 @@ defmodule BDS.Embeddings do
order_by: [asc: post.created_at, asc: post.slug] order_by: [asc: post.created_at, asc: post.slug]
) )
Enum.each(posts, fn post -> existing_keys = preload_keys_by_post_id(project_id)
body = resolve_post_body(post)
content_hash = hash_text(compose_embedding_source(post.title, body))
case Repo.get_by(Key, post_id: post.id, project_id: project_id) do
%Key{content_hash: ^content_hash} ->
:ok
_other ->
:ok =
sync_post_if_enabled(
%{post | content: if(post.content in [nil, ""], do: body, else: post.content)},
refresh_index: false
)
end
end)
case build_key_rows(posts, existing_keys, max_label_value(), nil, false) do
{:ok, rows} ->
batch_upsert_keys(rows)
:ok = rebuild_snapshot(project_id) :ok = rebuild_snapshot(project_id)
indexed = indexed =
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
@@ -263,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
@@ -297,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(
@@ -354,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
@@ -457,17 +576,35 @@ 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)
@@ -574,11 +711,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)
@@ -603,13 +771,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, ""],
@@ -645,8 +806,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,211 @@
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.
Hardware acceleration follows the `NativeAcceleratedExecution` invariant.
The serving's defn compiler is chosen at build time:
* On Apple Silicon (arm64 macOS) with EMLX available, inference runs on the
Apple GPU via MLX/Metal (`compiler: EMLX`, params placed on the
`EMLX.Backend` GPU device).
* Everywhere else — and as a fallback when EMLX is unavailable or explicitly
disabled — it runs on optimised native CPU via XLA (`compiler: EXLA`).
The accelerator can be pinned with `config :bds, :embeddings, accelerator:`
to `:auto` (default), `:emlx`, or `:exla`.
"""
@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
@default_accelerator :auto
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)}
accelerator = current_accelerator()
maybe_set_default_backend(accelerator)
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: defn_options(accelerator)
)
{:ok, serving}
end
end
# Place model params/tensors on the Apple GPU (Metal) when accelerating with
# EMLX so the compiled inference pass actually runs on-device. EXLA manages
# its own device placement, so nothing to do there.
defp maybe_set_default_backend(:emlx),
do: Nx.global_default_backend({EMLX.Backend, device: :gpu})
defp maybe_set_default_backend(:exla), do: :ok
@doc false
@spec defn_options(:emlx | :exla) :: keyword()
def defn_options(:emlx), do: [compiler: EMLX]
def defn_options(:exla), do: [compiler: EXLA]
@doc false
@spec current_accelerator() :: :emlx | :exla
def current_accelerator do
select_accelerator(configured_accelerator(), emlx_available?(), apple_silicon?())
end
@doc """
Pure accelerator-selection policy for `NativeAcceleratedExecution`.
Prefer the Apple GPU (EMLX) under `:auto` only when it is both available and
running on Apple Silicon; honour an explicit `:emlx`/`:exla` request, but
degrade a forced `:emlx` to EXLA when EMLX is not loaded so a misconfigured
host still gets working CPU inference instead of crashing.
"""
@spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean()) :: :emlx | :exla
def select_accelerator(:exla, _emlx_available?, _apple_silicon?), do: :exla
def select_accelerator(:emlx, true, _apple_silicon?), do: :emlx
def select_accelerator(:emlx, false, _apple_silicon?), do: :exla
def select_accelerator(:auto, true, true), do: :emlx
def select_accelerator(:auto, _emlx_available?, _apple_silicon?), do: :exla
defp configured_accelerator do
config() |> Keyword.get(:accelerator, @default_accelerator)
end
defp emlx_available? do
Code.ensure_loaded?(EMLX) and Code.ensure_loaded?(EMLX.Backend)
end
defp apple_silicon? do
:os.type() == {:unix, :darwin} and
to_string(:erlang.system_info(:system_architecture)) =~ ~r/aarch64|arm/
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,364 @@
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
entries = @doc """
keys Finds near-duplicate pairs at/above `threshold` by querying the HNSW graph for
|> Enum.map(fn key -> each entry's neighbours. `{:error, :missing}` if no index is available.
vector = decode_vector(key.vector) """
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
@doc "Forces a pending save for a project to disk now (e.g. on project switch)."
def flush(project_id) when is_binary(project_id) do
GenServer.call(__MODULE__, {:flush, project_id}, :infinity)
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
# Cancel any pending debounce for this project first: build_entry/2 returns a
# fresh entry with timer: nil, so without this the previous timer would be
# orphaned (left to fire a redundant save) instead of coalescing.
state = cancel_pending_save(state, project_id)
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 cancel_pending_save(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) %{timer: timer} = entry when is_reference(timer) ->
Process.cancel_timer(timer)
Map.put(state, project_id, %{entry | timer: nil})
if File.exists?(legacy_path) do _other ->
File.rm(legacy_path) state
end
end end
cleanup_legacy_project_snapshots(project_id, snapshot_path) defp save_now(state, project_id) do
case Map.get(state, project_id) do
nil ->
state
entry ->
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
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

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

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))
@@ -21,7 +23,7 @@ defmodule BDS.Generation.Outputs do
Enum.reject(route_posts, fn post -> Enum.reject(route_posts, fn post ->
is_binary(Map.get(post, :translation_source_slug)) and is_binary(Map.get(post, :translation_source_slug)) and
MapSet.member?(subtree_languages, to_string(Map.get(post, :language))) MapSet.member?(subtree_languages, to_string(post.language))
end) end)
end end
@@ -80,10 +82,12 @@ defmodule BDS.Generation.Outputs do
def category_route_paths(plan, posts_by_category, route_language) do def category_route_paths(plan, posts_by_category, route_language) do
if :category in plan.sections do if :category in plan.sections do
Enum.flat_map(posts_by_category, fn {category, posts} -> Enum.flat_map(posts_by_category, fn {category, posts} ->
post_count = length(posts)
paginated_archive_paths( paginated_archive_paths(
route_language, route_language,
["category", archive_route_segment(category)], ["category", archive_route_segment(category)],
length(posts), post_count,
plan.max_posts_per_page plan.max_posts_per_page
) )
end) end)
@@ -96,10 +100,12 @@ defmodule BDS.Generation.Outputs do
def tag_route_paths(plan, posts_by_tag, route_language) do def tag_route_paths(plan, posts_by_tag, route_language) do
if :tag in plan.sections do if :tag in plan.sections do
Enum.flat_map(posts_by_tag, fn {tag, posts} -> Enum.flat_map(posts_by_tag, fn {tag, posts} ->
post_count = length(posts)
paginated_archive_paths( paginated_archive_paths(
route_language, route_language,
["tag", archive_route_segment(tag)], ["tag", archive_route_segment(tag)],
length(posts), post_count,
plan.max_posts_per_page plan.max_posts_per_page
) )
end) end)
@@ -113,10 +119,12 @@ defmodule BDS.Generation.Outputs do
if :date in plan.sections do if :date in plan.sections do
year_paths = year_paths =
Enum.flat_map(post_index.posts_by_year, fn {year, posts} -> Enum.flat_map(post_index.posts_by_year, fn {year, posts} ->
post_count = length(posts)
paginated_archive_paths( paginated_archive_paths(
route_language, route_language,
[Integer.to_string(year)], [Integer.to_string(year)],
length(posts), post_count,
plan.max_posts_per_page plan.max_posts_per_page
) )
end) end)
@@ -124,11 +132,12 @@ defmodule BDS.Generation.Outputs do
month_paths = month_paths =
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} -> Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
[year, month] = String.split(year_month, "/", parts: 2) [year, month] = String.split(year_month, "/", parts: 2)
post_count = length(posts)
paginated_archive_paths( paginated_archive_paths(
route_language, route_language,
[year, month], [year, month],
length(posts), post_count,
plan.max_posts_per_page plan.max_posts_per_page
) )
end) end)
@@ -136,11 +145,12 @@ defmodule BDS.Generation.Outputs do
day_paths = day_paths =
Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} -> Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} ->
[year, month, day] = String.split(year_month_day, "/", parts: 3) [year, month, day] = String.split(year_month_day, "/", parts: 3)
post_count = length(posts)
paginated_archive_paths( paginated_archive_paths(
route_language, route_language,
[year, month, day], [year, month, day],
length(posts), post_count,
plan.max_posts_per_page plan.max_posts_per_page
) )
end) end)
@@ -383,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,
@@ -415,20 +427,22 @@ 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,
content: body, content: body,
slug: post.slug, slug: post.slug,
language: Map.get(post, :language), language: post.language,
excerpt: post.excerpt, excerpt: post.excerpt,
_post_record: post _post_record: post
}, },
fn -> render_post_page(post.title, body, post.slug, Map.get(post, :language)) end fn -> render_post_page(post.title, body, post.slug, post.language) end
)} )}
end) end)
end) end)
@@ -513,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,
@@ -543,24 +559,40 @@ 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,
content: body, content: body,
slug: post.slug, slug: post.slug,
language: Map.get(post, :language), language: post.language,
excerpt: post.excerpt, excerpt: post.excerpt,
_post_record: post _post_record: post
}, },
fn -> render_post_page(post.title, body, post.slug, Map.get(post, :language)) end fn -> render_post_page(post.title, body, post.slug, post.language) end
)} )}
end) end)
end) end)
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,95 @@ 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

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

View File

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

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

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

242
lib/bds/mac_bundle.ex Normal file
View File

@@ -0,0 +1,242 @@
defmodule BDS.MacBundle do
@moduledoc """
Assembles a self-contained, ad-hoc-signed macOS `.app` bundle for bDS2.
The bundle wraps the `:bds` Elixir release (which already embeds ERTS), a
red-pen `AppIcon.icns` (`BDS.MacBundle.Icon`), and the wxWidgets dylibs the
`:wx` NIF needs, relocated to run on a clean Mac (`BDS.MacBundle.Dylibs`). Its
`Info.plist` registers the `bds2://` URL scheme so the OS forwards blogmark
deep links to the running app (`Desktop.Env` → `BDS.Desktop.DeepLink`).
Layout:
BDS2.app/Contents/
Info.plist PkgInfo
MacOS/bds2 # launcher script (execs the release)
Resources/AppIcon.icns
Resources/rel/ # the mix release (ERTS bundled)
Frameworks/ # relocated wx + transitive dylibs
"""
alias BDS.MacBundle.Dylibs
@identifier "de.rfc1437.bds2"
@display_name "Blogging Desktop Server"
@executable "bds2"
@icon_name "AppIcon"
@scheme "bds2"
@min_system "13.0"
@release_name "bds"
@app_dir_name "BDS2.app"
@doc "Render the `Info.plist` XML for the bundle."
@spec info_plist(keyword()) :: String.t()
def info_plist(opts \\ []) do
assigns = [
identifier: Keyword.get(opts, :identifier, @identifier),
name: Keyword.get(opts, :name, @display_name),
executable: Keyword.get(opts, :executable, @executable),
icon: Keyword.get(opts, :icon, @icon_name),
scheme: Keyword.get(opts, :scheme, @scheme),
min_system: Keyword.get(opts, :min_system, @min_system),
version: Keyword.get(opts, :version, "0.0.0")
]
EEx.eval_file(template_path("Info.plist.eex"), assigns)
end
@doc "Render the `Contents/MacOS` launcher shell script."
@spec launcher(keyword()) :: String.t()
def launcher(opts \\ []) do
assigns = [
identifier: Keyword.get(opts, :identifier, @identifier),
name: Keyword.get(opts, :name, @display_name),
release_name: Keyword.get(opts, :release_name, @release_name)
]
EEx.eval_file(template_path("launcher.sh.eex"), assigns)
end
@doc """
Assemble the `.app` from a built release directory.
Options:
* `:release_dir` — path to the `:bds` release (required)
* `:output_dir` — where to place `BDS2.app` (required)
* `:icns_path` — path to `AppIcon.icns` (required)
* `:version` — version string for `Info.plist` (default "0.0.0")
* `:skip_dylibs` — skip wx dylib relocation (tests; default false)
* `:skip_codesign`— skip ad-hoc codesign (tests; default false)
"""
@spec assemble(keyword()) :: {:ok, String.t()} | {:error, term()}
def assemble(opts) do
release_dir = Keyword.fetch!(opts, :release_dir)
output_dir = Keyword.fetch!(opts, :output_dir)
icns_path = Keyword.fetch!(opts, :icns_path)
version = Keyword.get(opts, :version, "0.0.0")
app = Path.join(output_dir, @app_dir_name)
contents = Path.join(app, "Contents")
macos = Path.join(contents, "MacOS")
resources = Path.join(contents, "Resources")
frameworks = Path.join(contents, "Frameworks")
rel = Path.join(resources, "rel")
File.rm_rf!(app)
Enum.each([macos, resources, frameworks], &File.mkdir_p!/1)
with {:ok, _} <- File.cp_r(release_dir, rel),
:ok <- File.cp(icns_path, Path.join(resources, "#{@icon_name}.icns")),
:ok <- File.write(Path.join(contents, "Info.plist"), info_plist(version: version)),
:ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"),
:ok <- write_launcher(Path.join(macos, @executable)),
:ok <- maybe_relocate_dylibs(rel, frameworks, opts),
:ok <- maybe_codesign(app, opts) do
{:ok, app}
end
end
@doc "Locate the wxWidgets NIF (`wxe_driver.so`) inside a release tree."
@spec wx_nif_path(String.t()) :: String.t() | nil
def wx_nif_path(release_dir) do
release_dir
|> Path.join("lib/wx-*/priv/wxe_driver.so")
|> Path.wildcard()
|> List.first()
end
defp write_launcher(path) do
with :ok <- File.write(path, launcher()) do
File.chmod(path, 0o755)
end
end
defp maybe_relocate_dylibs(_rel, _frameworks, opts) do
if Keyword.get(opts, :skip_dylibs, false) do
:ok
else
relocate_dylibs(opts[:release_dir], opts)
end
end
defp relocate_dylibs(release_dir, opts) do
app = Path.join(opts[:output_dir], @app_dir_name)
frameworks = Path.join(app, "Contents/Frameworks")
case wx_nif_path(Path.join(app, "Contents/Resources/rel")) do
nil ->
{:error, {:wx_nif_not_found, release_dir}}
nif ->
prefix = "@loader_path/" <> relative_up(Path.dirname(nif), frameworks)
case Dylibs.bundle(nif, frameworks, prefix) do
{:ok, _copied} -> :ok
error -> error
end
end
end
@doc """
Compute a `../`-style relative path from `from_dir` to `to` (both absolute),
e.g. the path from the wx NIF's directory up to `Contents/Frameworks`.
"""
@spec relative_up(String.t(), String.t()) :: String.t()
def relative_up(from_dir, to) do
from = Path.split(Path.expand(from_dir))
target = Path.split(Path.expand(to))
{rest_from, rest_to} = drop_common(from, target)
(List.duplicate("..", length(rest_from)) ++ rest_to) |> Path.join()
end
defp drop_common([h | from], [h | to]), do: drop_common(from, to)
defp drop_common(from, to), do: {from, to}
defp maybe_codesign(app, opts) do
if Keyword.get(opts, :skip_codesign, false), do: :ok, else: codesign(app)
end
# Mach-O magic numbers (thin 32/64-bit, both endiannesses, and fat) as the
# first four bytes appear on disk.
@macho_magics [
<<0xCF, 0xFA, 0xED, 0xFE>>,
<<0xCE, 0xFA, 0xED, 0xFE>>,
<<0xFE, 0xED, 0xFA, 0xCF>>,
<<0xFE, 0xED, 0xFA, 0xCE>>,
<<0xCA, 0xFE, 0xBA, 0xBE>>,
<<0xBE, 0xBA, 0xFE, 0xCA>>
]
@doc """
Ad-hoc codesign the bundle.
`codesign --deep` only recurses into nested *bundles* and the main executable;
it ignores the loose Mach-O files the release ships — `beam.smp`, `erlexec`,
and every NIF `.so`/`.dylib` under `Resources/rel/…`. Those keep their original
*linker-signed* ad-hoc signatures, which the macOS code-signing monitor rejects
once the files are copied into a new bundle (the process is SIGKILLed with
"Code Signature Invalid" the moment it `dlopen`s such a NIF).
So we sign every Mach-O explicitly with a fresh ad-hoc signature (inside-out),
then seal the outer bundle and verify it.
"""
@spec codesign(String.t()) :: :ok | {:error, term()}
def codesign(app) do
with :ok <- sign_machos(macho_files(app)),
:ok <- run("codesign", ["--force", "--sign", "-", "--timestamp=none", app]) do
run("codesign", ["--verify", "--strict", app])
end
end
@doc """
List every Mach-O file under `root`, detected by magic number. These are the
loose binaries `codesign --deep` skips and that must be signed individually.
"""
@spec macho_files(String.t()) :: [String.t()]
def macho_files(root) do
root |> regular_files() |> Enum.filter(&macho?/1)
end
defp regular_files(path) do
case File.ls(path) do
{:ok, entries} ->
Enum.flat_map(entries, fn entry ->
full = Path.join(path, entry)
case File.lstat(full) do
{:ok, %File.Stat{type: :directory}} -> regular_files(full)
{:ok, %File.Stat{type: :regular}} -> [full]
_ -> []
end
end)
{:error, _} ->
[]
end
end
defp macho?(file) do
case File.open(file, [:read, :binary], &IO.binread(&1, 4)) do
{:ok, magic} when magic in @macho_magics -> true
_ -> false
end
end
defp sign_machos(files) do
Enum.reduce_while(files, :ok, fn file, :ok ->
case run("codesign", ["--force", "--sign", "-", "--timestamp=none", file]) do
:ok -> {:cont, :ok}
error -> {:halt, error}
end
end)
end
defp run(cmd, args) do
case System.cmd(cmd, args, stderr_to_stdout: true) do
{_out, 0} -> :ok
{out, status} -> {:error, {:command_failed, cmd, status, out}}
end
end
defp template_path(name), do: Path.join(:code.priv_dir(:bds), "desktop/macos/#{name}")
end

View File

@@ -0,0 +1,184 @@
defmodule BDS.MacBundle.Dylibs do
@moduledoc """
Makes the bundled Elixir release self-contained on macOS by relocating the
non-system dynamic libraries the wxWidgets NIF (`wxe_driver.so`) links against.
Erlang's `:wx` driver is built against the wxWidgets dylibs of whatever host
produced the OTP install (typically Homebrew under `/opt/homebrew`), so a
release copied to a clean Mac would fail to load `:wx`. We copy every external
dylib (and its transitive deps) into `Contents/Frameworks/`, then rewrite the
install names with `install_name_tool` to `@executable_path/../Frameworks/...`.
The `otool`/`install_name_tool` parsing and argument building are pure so they
can be unit-tested; `bundle/2` performs the actual filesystem + tool work.
"""
# Homebrew prefixes whose libraries must be copied into the bundle. Anything
# under /usr/lib or /System is part of macOS and stays referenced in place;
# @rpath/@executable_path/@loader_path are already relocatable.
@external_prefixes ["/opt/homebrew", "/usr/local"]
@doc "Parse `otool -L` output into the ordered list of dependency paths."
@spec parse_otool(String.t()) :: [String.t()]
def parse_otool(output) when is_binary(output) do
output
|> String.split("\n", trim: true)
# The first line is the inspected file ("path:"), not a dependency.
|> Enum.filter(&String.starts_with?(&1, "\t"))
|> Enum.map(fn line ->
line
|> String.trim()
|> String.replace(~r/\s+\(compatibility version.*\)$/, "")
|> String.trim()
end)
end
@doc "True when a dylib path must be copied into the bundle (Homebrew/local)."
@spec external?(String.t()) :: boolean()
def external?(path) when is_binary(path) do
Enum.any?(@external_prefixes, &String.starts_with?(path, &1))
end
@doc "install_name_tool args to set a dylib's own id."
@spec id_args(String.t(), String.t()) :: [String.t()]
def id_args(dylib_path, new_id), do: ["-id", new_id, dylib_path]
@doc """
install_name_tool args to rewrite zero or more dependency install names on a
binary. Returns `[]` when there are no changes so callers can skip the tool.
"""
@spec change_args(String.t(), [{String.t(), String.t()}]) :: [String.t()]
def change_args(_binary, []), do: []
def change_args(binary, changes) when is_list(changes) do
Enum.flat_map(changes, fn {old, new} -> ["-change", old, new] end) ++ [binary]
end
@doc """
Copy every external dylib reachable from `nif_path` into `frameworks_dir` and
rewrite all install names (in the NIF and the copied dylibs) to point inside
the bundle. Returns `{:ok, copied_paths}`.
References are made `@loader_path`-relative so they resolve against the binary
that triggers the load — independent of where the host process executable
lives (the release runs `beam.smp`, not the bundle launcher) and without any
`DYLD_*` env reliance:
* the NIF references each dylib as `<nif_loader_prefix>/<basename>`, where
`nif_loader_prefix` is the `@loader_path/../…/Frameworks` path from the
NIF's directory to `frameworks_dir`;
* the copied dylibs live together, so they reference each other (and their
own id) as `@loader_path/<basename>`.
"""
@spec bundle(String.t(), String.t(), String.t()) ::
{:ok, [String.t()]} | {:error, term()}
def bundle(nif_path, frameworks_dir, nif_loader_prefix) do
File.mkdir_p!(frameworks_dir)
with {:ok, {_seen, externals}} <- collect(nif_path, MapSet.new(), []) do
externals = Enum.reverse(externals)
{physical, logical_entries, _inode_map} =
externals
|> Enum.reduce({[], [], %{}}, fn src, {phys_acc, log_acc, inode_map} ->
dest = Path.join(frameworks_dir, Path.basename(src))
if File.exists?(dest) do
{phys_acc, [{src, dest} | log_acc], inode_map}
else
ino = file_inode(src)
case Map.get(inode_map, ino) do
nil ->
File.cp!(src, dest)
File.chmod!(dest, 0o644)
{[{src, dest} | phys_acc], [{src, dest} | log_acc],
Map.put(inode_map, ino, dest)}
existing_dest ->
# Same inode already copied under a different name.
# Point this logical entry to the physical copy.
{phys_acc, [{src, existing_dest} | log_acc], inode_map}
end
end
end)
copied = Enum.reverse(logical_entries)
physical = Enum.reverse(physical)
with :ok <- rewrite(nif_path, copied, nif_loader_prefix),
:ok <- rewrite_each(physical) do
{:ok, Enum.map(physical, &elem(&1, 1))}
end
end
end
defp file_inode(path) do
stat = File.stat!(path)
{stat.major_device, stat.inode}
end
# Depth-first transitive collection of external dependency paths. Returns
# `{:ok, {seen, acc}}` where `acc` is the reverse-discovery-order path list.
defp collect(binary, seen, acc) do
case otool(binary) do
{:ok, deps} ->
deps
|> Enum.filter(&external?/1)
|> Enum.reduce_while({:ok, {seen, acc}}, fn dep, {:ok, {seen_acc, list_acc}} ->
if MapSet.member?(seen_acc, dep) do
{:cont, {:ok, {seen_acc, list_acc}}}
else
seen_acc = MapSet.put(seen_acc, dep)
case collect(dep, seen_acc, [dep | list_acc]) do
{:ok, _} = ok -> {:cont, ok}
error -> {:halt, error}
end
end
end)
error ->
error
end
end
defp rewrite(binary, copied, prefix) do
changes =
Enum.map(copied, fn {src, dest} ->
{src, prefix <> "/" <> Path.basename(dest)}
end)
case change_args(binary, changes) do
[] -> :ok
args -> run("install_name_tool", args)
end
end
# Copied dylibs share Frameworks/, so they reference each other (and their own
# id) relative to their own location with @loader_path.
defp rewrite_each(copied) do
Enum.reduce_while(copied, :ok, fn {_src, dest}, _acc ->
base = Path.basename(dest)
with :ok <- run("install_name_tool", id_args(dest, "@loader_path/" <> base)),
:ok <- rewrite(dest, copied, "@loader_path") do
{:cont, :ok}
else
error -> {:halt, error}
end
end)
end
defp otool(path) do
case System.cmd("otool", ["-L", path], stderr_to_stdout: true) do
{out, 0} -> {:ok, parse_otool(out)}
{out, status} -> {:error, {:otool_failed, status, out}}
end
end
defp run(cmd, args) do
case System.cmd(cmd, args, stderr_to_stdout: true) do
{_out, 0} -> :ok
{out, status} -> {:error, {:command_failed, cmd, status, out}}
end
end
end

195
lib/bds/mac_bundle/icon.ex Normal file
View File

@@ -0,0 +1,195 @@
defmodule BDS.MacBundle.Icon do
@moduledoc """
Builds the macOS `AppIcon.icns` for the bDS2 bundle.
The source artwork is the legacy app's pen icon (a gold pen on a gold disc,
imported as `priv/desktop/macos/icon-source.svg`). The bundle icon recolours
the **blue** pen to **red** by rotating any colour whose hue sits in the blue
band, leaving the gold body and warm/neutral tones untouched. Recolouring the
SVG text is fully deterministic and unit-testable; rasterisation then uses the
bundled libvips (`Image`) so no extra system tooling is required for SVG→PNG.
`.icns` packaging itself shells out to the macOS-only `sips`/`iconutil`.
"""
# Hue band (degrees) that counts as "blue" and gets rotated to red.
@blue_low 195
@blue_high 265
# Rotation that maps the pen's ~218221° blues onto ~3581° (red).
@hue_shift 220
# Standard macOS iconset base sizes; each is emitted at @1x and @2x.
@iconset_sizes [16, 32, 128, 256, 512]
@doc "Absolute path to the (blue) source SVG bundled in priv."
@spec source_svg_path() :: String.t()
def source_svg_path do
Application.get_env(:bds, :mac_icon_source_svg) ||
Path.join(:code.priv_dir(:bds), "desktop/macos/icon-source.svg")
end
@doc "Default output path for the generated `.icns`."
@spec icns_path() :: String.t()
def icns_path, do: Path.join(:code.priv_dir(:bds), "desktop/macos/AppIcon.icns")
@doc """
Recolour a single `#RRGGBB` value. Blue-band colours are hue-rotated to red;
everything else is returned unchanged (byte-for-byte, no rounding drift).
"""
@spec recolor_color(String.t()) :: String.t()
def recolor_color("#" <> _ = hex) do
{r, g, b} = to_rgb(hex)
{h, s, l} = rgb_to_hsl(r, g, b)
if h >= @blue_low and h < @blue_high do
new_h = :math.fmod(h - @hue_shift + 360.0, 360.0)
{nr, ng, nb} = hsl_to_rgb(new_h, s, l)
to_hex(nr, ng, nb)
else
String.upcase(hex)
end
end
@doc "Recolour every `#RRGGBB` literal found in an SVG (or any text)."
@spec recolor_svg(String.t()) :: String.t()
def recolor_svg(svg) when is_binary(svg) do
Regex.replace(~r/#[0-9A-Fa-f]{6}/, svg, fn hex -> recolor_color(hex) end)
end
@doc """
Generate the `.icns` from the source SVG. Returns `{:ok, icns_path}`.
Steps: recolour SVG → render a 1024² PNG via libvips → emit the iconset sizes
with `sips` → pack with `iconutil`.
"""
@spec generate(keyword()) :: {:ok, String.t()} | {:error, term()}
def generate(opts \\ []) do
source = Keyword.get(opts, :source_svg, source_svg_path())
icns = Keyword.get(opts, :icns_path, icns_path())
png_1024 = Keyword.get(opts, :png_path, Path.rootname(icns) <> "-1024.png")
with {:ok, svg} <- File.read(source),
:ok <- render_png(recolor_svg(svg), png_1024, 1024),
{:ok, iconset} <- build_iconset(png_1024),
:ok <- run("iconutil", ["-c", "icns", "-o", icns, iconset]) do
File.rm_rf(iconset)
{:ok, icns}
end
end
@doc "Render an SVG string to a square PNG of `size` pixels via libvips."
@spec render_png(String.t(), String.t(), pos_integer()) :: :ok | {:error, term()}
def render_png(svg, png_path, size) do
with {:ok, image} <- Image.from_binary(svg),
{:ok, resized} <- Image.thumbnail(image, size, height: size),
{:ok, _} <- Image.write(resized, png_path) do
:ok
end
end
defp build_iconset(png_1024) do
iconset = Path.rootname(png_1024) <> ".iconset"
File.rm_rf!(iconset)
File.mkdir_p!(iconset)
Enum.reduce_while(@iconset_sizes, {:ok, iconset}, fn base, acc ->
with :ok <- emit_size(png_1024, iconset, base, 1),
:ok <- emit_size(png_1024, iconset, base, 2) do
{:cont, acc}
else
error -> {:halt, error}
end
end)
end
defp emit_size(png_1024, iconset, base, scale) do
px = base * scale
suffix = if scale == 1, do: "#{base}x#{base}", else: "#{base}x#{base}@2x"
out = Path.join(iconset, "icon_#{suffix}.png")
run("sips", ["-z", Integer.to_string(px), Integer.to_string(px), png_1024, "--out", out])
end
defp run(cmd, args) do
case System.cmd(cmd, args, stderr_to_stdout: true) do
{_out, 0} -> :ok
{out, status} -> {:error, {:command_failed, cmd, status, out}}
end
end
# ---- colour space helpers -------------------------------------------------
defp to_rgb("#" <> hex) do
{
String.to_integer(String.slice(hex, 0, 2), 16),
String.to_integer(String.slice(hex, 2, 2), 16),
String.to_integer(String.slice(hex, 4, 2), 16)
}
end
defp to_hex(r, g, b) do
"#" <> pad(r) <> pad(g) <> pad(b)
end
defp pad(v) do
v
|> max(0)
|> min(255)
|> Integer.to_string(16)
|> String.upcase()
|> String.pad_leading(2, "0")
end
defp rgb_to_hsl(r, g, b) do
rf = r / 255.0
gf = g / 255.0
bf = b / 255.0
max = Enum.max([rf, gf, bf])
min = Enum.min([rf, gf, bf])
l = (max + min) / 2.0
d = max - min
if d == 0.0 do
{0.0, 0.0, l}
else
s = if l > 0.5, do: d / (2.0 - max - min), else: d / (max + min)
h =
cond do
max == rf -> :math.fmod((gf - bf) / d + 6.0, 6.0)
max == gf -> (bf - rf) / d + 2.0
true -> (rf - gf) / d + 4.0
end
{h * 60.0, s, l}
end
end
defp hsl_to_rgb(_h, s, l) when s == 0.0 do
v = round(l * 255.0)
{v, v, v}
end
defp hsl_to_rgb(h, s, l) do
q = if l < 0.5, do: l * (1.0 + s), else: l + s - l * s
p = 2.0 * l - q
hk = h / 360.0
{
round(hue_to_channel(p, q, hk + 1.0 / 3.0) * 255.0),
round(hue_to_channel(p, q, hk) * 255.0),
round(hue_to_channel(p, q, hk - 1.0 / 3.0) * 255.0)
}
end
defp hue_to_channel(p, q, t) do
t = :math.fmod(t + 1.0, 1.0)
cond do
t < 1.0 / 6.0 -> p + (q - p) * 6.0 * t
t < 1.0 / 2.0 -> q
t < 2.0 / 3.0 -> p + (q - p) * (2.0 / 3.0 - t) * 6.0
true -> p
end
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

@@ -71,7 +71,7 @@ defmodule BDS.MCP.Tools do
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}} @spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
def validate_template(source) when is_binary(source) do def validate_template(source) when is_binary(source) do
case Liquex.parse(source) do case BDS.Rendering.LiquidParser.validate(source) do
{:ok, _ast} -> {:ok, _ast} ->
{:ok, %{valid: true, errors: []}} {:ok, %{valid: true, errors: []}}

View File

@@ -106,7 +106,7 @@ defmodule BDS.Media do
|> Repo.insert!() |> Repo.insert!()
end) do end) do
{:ok, media} -> {:ok, media} ->
:ok = write_sidecar(project, media) log_sidecar_error(write_sidecar(project, media), media.id)
log_thumbnail_error(ensure_thumbnails(project, media), media.id) log_thumbnail_error(ensure_thumbnails(project, media), media.id)
:ok = Search.sync_media(media) :ok = Search.sync_media(media)
{:ok, media} {:ok, media}
@@ -148,7 +148,7 @@ defmodule BDS.Media do
|> Repo.update!() |> Repo.update!()
end) do end) do
{:ok, updated_media} -> {:ok, updated_media} ->
:ok = write_sidecar(project, updated_media) log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
:ok = Search.sync_media(updated_media) :ok = Search.sync_media(updated_media)
{:ok, updated_media} {:ok, updated_media}
@@ -240,7 +240,11 @@ defmodule BDS.Media do
|> Repo.insert_or_update!() |> Repo.insert_or_update!()
end) do end) do
{:ok, updated_translation} -> {:ok, updated_translation} ->
:ok = write_translation_sidecar(project, media, updated_translation) log_sidecar_error(
write_translation_sidecar(project, media, updated_translation),
media.id
)
:ok = Search.sync_media(media.id) :ok = Search.sync_media(media.id)
{:ok, updated_translation} {:ok, updated_translation}
@@ -275,7 +279,7 @@ defmodule BDS.Media do
) )
:ok = Search.sync_media(media) :ok = Search.sync_media(media)
:ok = write_sidecar(project, media) log_sidecar_error(write_sidecar(project, media), media.id)
{:ok, true} {:ok, true}
{:error, changeset} -> {:error, changeset} ->
@@ -322,7 +326,7 @@ defmodule BDS.Media do
end) do end) do
{:ok, updated_media} -> {:ok, updated_media} ->
_ = File.rm(previous_destination_backup) _ = File.rm(previous_destination_backup)
:ok = write_sidecar(project, updated_media) log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
log_thumbnail_error(ensure_thumbnails(project, updated_media), updated_media.id) log_thumbnail_error(ensure_thumbnails(project, updated_media), updated_media.id)
:ok = Search.sync_media(updated_media) :ok = Search.sync_media(updated_media)
{:ok, updated_media} {:ok, updated_media}
@@ -345,9 +349,62 @@ defmodule BDS.Media do
) )
end end
@spec validate_media(String.t()) :: [%{media_id: String.t(), issue: String.t()}]
def validate_media(project_id) do
project = BDS.Projects.get_project!(project_id)
data_dir = BDS.Projects.project_data_dir(project)
Repo.all(from m in Media, where: m.project_id == ^project_id)
|> Enum.flat_map(fn media ->
issues = []
binary_path = Path.join(data_dir, media.file_path)
issues = if File.exists?(binary_path), do: issues, else: [%{media_id: media.id, issue: "missing_binary"} | issues]
sidecar_path = Path.join(data_dir, media.sidecar_path)
issues = if File.exists?(sidecar_path), do: issues, else: [%{media_id: media.id, issue: "missing_sidecar"} | issues]
issues =
if BDS.Media.FileOps.image_mime?(media.mime_type) do
thumbnails = BDS.Media.Thumbnails.thumbnail_paths(media)
Enum.reduce([:small, :medium, :large, :ai], issues, fn size, acc ->
thumb_path = Path.join(data_dir, Map.fetch!(thumbnails, size))
if File.exists?(thumb_path),
do: acc,
else: [%{media_id: media.id, issue: "missing_thumbnail_#{size}"} | acc]
end)
else
issues
end
issues =
if BDS.Media.FileOps.image_mime?(media.mime_type) and File.exists?(binary_path) do
case BDS.Media.FileOps.image_dimensions(binary_path, media.mime_type) do
{nil, nil} -> [%{media_id: media.id, issue: "corrupted"} | issues]
_ -> issues
end
else
issues
end
linked_posts = BDS.Media.Linking.list_linked_posts(media.id)
issues = if linked_posts == [], do: [%{media_id: media.id, issue: "orphan"} | issues], else: issues
Enum.reverse(issues)
end)
end
defp log_thumbnail_error(:ok, _media_id), do: :ok defp log_thumbnail_error(:ok, _media_id), do: :ok
defp log_thumbnail_error({:error, reason}, media_id) do defp log_thumbnail_error({:error, reason}, media_id) do
Logger.warning("Thumbnail generation failed for media #{media_id}: #{inspect(reason)}") Logger.warning("Thumbnail generation failed for media #{media_id}: #{inspect(reason)}")
end end
defp log_sidecar_error(:ok, _media_id), do: :ok
defp log_sidecar_error({:error, reason}, media_id) do
Logger.warning("Sidecar write failed for media #{media_id}: #{inspect(reason)}")
end
end end

View File

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

View File

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

View File

@@ -62,6 +62,6 @@ defmodule BDS.Media.Translation do
:updated_at :updated_at
]) ])
|> foreign_key_constraint(:translation_for) |> foreign_key_constraint(:translation_for)
|> unique_constraint(:language, name: :media_translations_translation_language_idx) |> unique_constraint(:language, name: :media_translations_translation_for_language_index)
end end
end end

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,
@@ -403,7 +417,7 @@ defmodule BDS.Metadata do
defp write_json(project, file_name, payload) do defp write_json(project, file_name, payload) do
meta_dir = Path.join(Projects.project_data_dir(project), "meta") meta_dir = Path.join(Projects.project_data_dir(project), "meta")
path = Path.join(meta_dir, file_name) path = Path.join(meta_dir, file_name)
Persistence.atomic_write(path, Jason.encode!(payload)) Persistence.atomic_write(path, Jason.encode!(payload, pretty: true))
end end
defp read_json(project, file_name) do defp read_json(project, file_name) do
@@ -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

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

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

@@ -79,6 +79,6 @@ defmodule BDS.Posts.Translation do
:updated_at :updated_at
]) ])
|> foreign_key_constraint(:translation_for) |> foreign_key_constraint(:translation_for)
|> unique_constraint(:language, name: :post_translations_translation_language_idx) |> unique_constraint(:language, name: :post_translations_translation_for_language_index)
end end
end end

View File

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

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
@@ -101,7 +103,7 @@ defmodule BDS.Preview do
with :ok <- ensure_running(state.current, project_id), with :ok <- ensure_running(state.current, project_id),
{:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do {:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
body = body =
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do case Rendering.render_post_page(project_id, payload.template_slug, payload) do
{:ok, rendered} -> rendered {:ok, rendered} -> rendered
{:error, _reason} -> render_draft(payload) {:error, _reason} -> render_draft(payload)
end end
@@ -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,31 +175,50 @@ 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
defp resolve_draft_request(project_id, post_id, query_params) do defp resolve_draft_request(project_id, post_id, query_params) do
with {:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do with {:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
body = body =
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do case Rendering.render_post_page(project_id, payload.template_slug, payload) do
{:ok, rendered} -> rendered {:ok, rendered} -> rendered
{:error, _reason} -> render_draft(payload) {:error, _reason} -> render_draft(payload)
end end
@@ -205,6 +245,10 @@ 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 +259,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 +271,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 +314,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 +448,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 +522,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}}

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

@@ -0,0 +1,579 @@
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

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

View File

@@ -69,6 +69,12 @@ defmodule BDS.Projects do
now = Persistence.now_ms() now = Persistence.now_ms()
is_active = not Repo.exists?(from project in Project, where: project.is_active == true) is_active = not Repo.exists?(from project in Project, where: project.is_active == true)
# The default project's public content folder is created at the per-user
# default content location on first launch — never in the repo or the
# private app dir (PublicContentLivesInProjectFolder).
data_path = default_project_dir(@default_project_id)
File.mkdir_p!(data_path)
Repo.transaction(fn -> Repo.transaction(fn ->
project = project =
%Project{} %Project{}
@@ -77,7 +83,7 @@ defmodule BDS.Projects do
name: @default_project_name, name: @default_project_name,
slug: unique_slug(Slug.slugify(@default_project_name)), slug: unique_slug(Slug.slugify(@default_project_name)),
description: nil, description: nil,
data_path: nil, data_path: data_path,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
is_active: is_active is_active: is_active
@@ -87,16 +93,51 @@ defmodule BDS.Projects do
project project
end) end)
|> case do |> case do
{:ok, project} -> rebuild_project_templates(project) {:ok, project} ->
{:error, reason} -> {:error, reason} record_project_location(project.id, data_path)
rebuild_project_templates(project)
{:error, reason} ->
{:error, reason}
end end
end end
end end
@spec project_data_dir(Project.t()) :: String.t() @spec project_data_dir(Project.t()) :: String.t()
def project_data_dir(%Project{} = project) do def project_data_dir(%Project{data_path: data_path})
project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__) when is_binary(data_path) and data_path != "",
do: data_path
# A project without an explicit data_path resolves to its folder under the
# per-user default content location — never priv/data inside the repo
# (PublicContentLivesInProjectFolder).
def project_data_dir(%Project{id: id}), do: default_project_dir(id)
@doc """
Per-user base directory that holds the public, portable content of projects
created without an explicit folder (the default project on first launch).
Configurable via `:default_content_root`; otherwise the user's home dir under
`bds/`. Never the application repo nor `private_dir/0`
(PublicContentLivesInProjectFolder).
"""
@spec default_content_root() :: String.t()
def default_content_root do
case Application.get_env(:bds, :default_content_root) do
root when is_binary(root) -> Path.expand(root)
_other -> Path.join(System.user_home!(), "bds")
end end
end
defp default_project_dir(project_id), do: Path.join(default_content_root(), project_id)
@doc """
The OS per-user app-data directory holding machine-specific, regenerable
artifacts only (database, embeddings index, model cache, project registry,
UI state) — never project content (PrivateArtifactsLiveInOsAppDir).
"""
@spec private_dir() :: String.t()
def private_dir, do: private_app_dir()
@spec project_cache_dir(Project.t() | String.t()) :: String.t() @spec project_cache_dir(Project.t() | String.t()) :: String.t()
def project_cache_dir(%Project{} = project), do: project_cache_dir(project.id) def project_cache_dir(%Project{} = project), do: project_cache_dir(project.id)
@@ -130,6 +171,8 @@ defmodule BDS.Projects do
end) end)
|> case do |> case do
{:ok, project} -> {:ok, project} ->
record_project_location(project.id, project_data_dir(project))
with {:ok, project} <- rebuild_project_templates(project) do with {:ok, project} <- rebuild_project_templates(project) do
sync_filesystem_metadata(project) sync_filesystem_metadata(project)
end end
@@ -148,6 +191,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 +205,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
@@ -181,10 +235,15 @@ defmodule BDS.Projects do
{:error, :cannot_delete_active_project} {:error, :cannot_delete_active_project}
%Project{} = project -> %Project{} = project ->
internal_dir = if is_nil(project.data_path), do: project_data_dir(project), else: nil data_dir = project_data_dir(project)
# App-managed folders (those under the per-user default content location)
# are removed; user-chosen external folders are preserved.
managed_dir =
if String.starts_with?(data_dir, default_content_root()), do: data_dir, else: nil
cleanup_dirs = cleanup_dirs =
[internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq() [managed_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
Repo.transaction(fn -> Repo.transaction(fn ->
case Repo.delete(project) do case Repo.delete(project) do
@@ -194,6 +253,9 @@ defmodule BDS.Projects do
end) end)
|> case do |> case do
{:ok, deleted_project} -> {:ok, deleted_project} ->
BDS.Embeddings.Index.forget(deleted_project.id)
forget_project_location(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)
@@ -255,20 +317,60 @@ defmodule BDS.Projects do
not Repo.exists?(from project in Project, where: project.slug == ^slug) not Repo.exists?(from project in Project, where: project.slug == ^slug)
end end
defp repo_data_dir do
Application.fetch_env!(:bds, BDS.Repo)
|> Keyword.fetch!(:database)
|> Path.expand()
|> Path.dirname()
end
defp project_cache_root do defp project_cache_root do
case Application.get_env(:bds, :project_cache_root) do case Application.get_env(:bds, :project_cache_root) do
root when is_binary(root) -> Path.expand(root) root when is_binary(root) -> Path.expand(root)
_other -> repo_data_dir() # Private app-internal artifacts (e.g. the embeddings index) live under the
# OS private app directory — on macOS ~/Library/Application Support/bds —
# never inside the repo or a project's public folder. Colocating them with
# project_data_dir would pollute (and historically committed to) the repo.
_other -> private_app_dir()
end end
end end
defp private_app_dir do
case :filename.basedir(:user_config, "BDS2") do
path when is_list(path) -> List.to_string(path)
path -> path
end
|> Path.expand()
end
@doc """
Path to the machine-local project registry: a `id => data_path` pointer file
under `private_dir/0` that remembers where each project's folder currently
lives. The folder location is never embedded in `meta/project.json`, so a
project folder can be moved or renamed and only the registry is updated
(DataPathNotPersistedInProjectJson).
"""
@spec registry_path() :: String.t()
def registry_path, do: Path.join(private_dir(), "project_registry.json")
@doc "Reads the machine-local project registry as an `id => data_path` map."
@spec project_registry() :: %{optional(String.t()) => String.t()}
def project_registry do
with {:ok, contents} <- File.read(registry_path()),
{:ok, map} when is_map(map) <- Jason.decode(contents) do
map
else
_ -> %{}
end
end
defp record_project_location(project_id, data_path) when is_binary(data_path) do
project_registry() |> Map.put(project_id, data_path) |> write_registry()
end
defp forget_project_location(project_id) do
project_registry() |> Map.delete(project_id) |> write_registry()
end
defp write_registry(registry) do
path = registry_path()
File.mkdir_p!(Path.dirname(path))
File.write(path, Jason.encode!(registry))
end
defp attr(attrs, key) do defp attr(attrs, key) do
cond do cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key) Map.has_key?(attrs, key) -> Map.get(attrs, key)

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