Compare commits

..

74 Commits

Author SHA1 Message Date
655069a28a fix: fix issue #23 broken css for link insert 2026-07-18 10:33:15 +02:00
593f1646a1 fix: made the macos app smaller 2026-07-18 10:20:54 +02:00
c77450d497 fix: broken CSS in the bottom panel fixed 2026-07-18 09:05:31 +02:00
23f05b72f7 fix: issue #35 validation expected unrendered .lang translation variant routes; main-language lists now render main-language translations 2026-07-17 22:12:23 +02:00
dd53ca3fbc fix: issue #20 cmd-J panel tab strip visibility and output panel parity 2026-07-17 20:51:42 +02:00
6c9dd9605b fix: updated current line indicator for issue #32 2026-07-17 16:43:57 +02:00
46c239df56 feat: issue #32 markdown editor for TUI 2026-07-17 16:38:46 +02:00
2fd132e827 feat: TUI tags panels (cloud, manage, merge) wired to the shared tags backend (issue #34) 2026-07-17 14:50:25 +02:00
0f3f1efa08 feat: issue #25 implemented - cli 2026-07-17 14:26:14 +02:00
e4fa61ae07 fix: fix for building 2026-07-17 12:04:07 +02:00
8fe18d108f fix: bundle @rpath dylib deps (libsharpyuv) and fail verify_standalone on unresolvable @rpath references 2026-07-17 10:58:51 +02:00
b517672663 fix: insert link/media lands at the cursor and closes the overlay instead of breaking the editor 2026-07-17 10:58:51 +02:00
b016f6d812 fix: update_project_metadata keeps fields absent from attrs instead of resetting them 2026-07-17 08:57:20 +02:00
505527ae4f feat: TUI settings panel on key 6 with per-section preference editors backed by the same GUI settings backends (issue #29) 2026-07-17 08:38:26 +02:00
b6b1d16e54 feat: posts open in the rendered markdown preview by default, ctrl+e toggles to the editor 2026-07-16 22:22:17 +02:00
46fd6c1b85 feat: git sidebar shows the commit history in its lower half like the GUI (issue #30) 2026-07-16 22:10:27 +02:00
5433cb59ac feat: TUI git panel on key 7 with change list, scrollable diff, commit, pull and push (issue #30) 2026-07-16 21:59:59 +02:00
e027364c0e feat: vi-style / search in the TUI sidebar with tag:, category: and ISO-date filters (issue #31) 2026-07-16 21:42:19 +02:00
3675a26407 fix: local tui mode routes console logging to a file and disables download progress bars so stray output cannot scroll the TUI 2026-07-16 18:39:07 +02:00
9a1f301527 feat: bash-style tab completion in the TUI project path prompt (issue #28) 2026-07-16 18:27:09 +02:00
59333ac920 fix: set NO_WX before dependency boot and detect headless sessions per platform so the TUI fallback actually works (issue #33) 2026-07-16 18:13:15 +02:00
381deb417d feat: desktop mode falls back to the TUI when no graphical display is available (issue #33) 2026-07-16 17:46:36 +02:00
5801e49dc1 feat: TUI projects overlay to switch projects and open existing blog folders with automatic database rebuild (issue #28) 2026-07-16 17:26:19 +02:00
a8ca3b643b fix: empty command-palette filter no longer crashes the TUI render (issue #26) 2026-07-15 20:20:30 +02:00
39bb232b57 feat: TUI report panels with whole-report apply for metadata diff and site validation (issue #26) 2026-07-15 20:12:45 +02:00
824313f106 feat: vi-style command prompt in the TUI for menu shell commands (issue #26) 2026-07-15 19:54:30 +02:00
01b90e00a0 feat: word-wrapped markdown preview toggle in the TUI editor (issue #26) 2026-07-15 19:28:10 +02:00
c77686250e fix: ctrl-q now properly exits 2026-07-15 19:17:21 +02:00
48e0903542 docs: server setup, TUI guide, and API doc sync for headless mode (issue #26, phase 6) 2026-07-15 18:45:39 +02:00
df2bdc2041 feat: desktop connect-to-server over SSH tunnel (issue #26, phase 5) 2026-07-15 18:39:04 +02:00
a3ca44640c feat: terminal UI over the shared workbench core (issue #26, phase 4) 2026-07-14 20:33:30 +02:00
3a8fafd2fd refactor: move pure post-editor logic to BDS.UI.PostEditor for TUI reuse (issue #26, phase 3) 2026-07-14 19:41:43 +02:00
16f45d77b2 feat: domain event bus for multi-client sync (issue #26, phase 2) 2026-07-14 19:33:07 +02:00
700ed79c43 feat: headless server mode with ExRatatui SSH daemon (issue #26, phase 1) 2026-07-14 19:17:06 +02:00
6ec61610f1 fix: issue #22 blogmarks not creating drafts 2026-07-13 10:45:22 +02:00
89859684eb fix: issue #19 opening post editor on blogmark 2026-07-08 18:58:06 +02:00
dc35518bad fix: ai preferences crashed and deleted stuff in preferences 2026-07-07 22:43:05 +02:00
4a03fc0ba2 fix: added homebrew to path for git 2026-07-06 21:06:37 +02:00
10fa174388 fix: issue #18 more visible git activities 2026-07-06 15:52:28 +02:00
8ee51c6626 fix: issue #8 title of upload task changed 2026-07-06 11:01:19 +02:00
d31ceb72cb fix: tests for removed assist cards 2026-07-06 10:55:41 +02:00
c8d2c73a72 fix: issue #1 button styling 2026-07-06 09:45:43 +02:00
a772e3abea fix: issue #5 now using full size images in editor 2026-07-06 08:36:49 +02:00
c735c1afa7 fix: issue #2 auto-translate only on manual save now 2026-07-05 19:22:44 +02:00
d85654d4b2 fix: issue #10 incomplete upload 2026-07-05 18:38:07 +02:00
ae2e534fdb fix: issue #9 moved windows menu 2026-07-05 18:13:07 +02:00
ae1c8703e7 fix: issue #16 broken diff visualisation 2026-07-05 17:40:46 +02:00
d944c07838 fix: issue #15 calendar index not properly created, also some smaller bugs 2026-07-05 17:33:48 +02:00
bb82f1d4a1 fix: issue #14 wrong sorting in category and tag archives 2026-07-05 16:09:33 +02:00
7dfcd78abd fix: issue #7 rebuild media links to get gallery working again 2026-07-05 14:44:47 +02:00
8e60fe31a5 fix: issue #7 with broken macro renderings for photo archive and tag cloud 2026-07-05 14:11:12 +02:00
28398b17f4 fix: issue #11 wrong page titles compared to old app 2026-07-05 12:58:44 +02:00
cf1d4bd46f fix: issue #6 had missing title suppression 2026-07-05 12:39:59 +02:00
7fa6e6232c fix: issue #6 post bodies missing for asides 2026-07-05 12:24:30 +02:00
fcc574428c fix: issue #3 slow rendering 2026-07-05 10:26:21 +02:00
1917043c10 fix: allow greater concurrency 2026-07-02 21:59:36 +02:00
4b52ac6156 fix: better progress tracking for full site render 2026-07-02 21:52:20 +02:00
1487c0337e fix: incremental rendering more on par with bDS 2026-07-02 18:31:17 +02:00
43f04dcd86 fix: finally fix the cursor key bug in monaco 2026-07-02 14:52:24 +02:00
c07ba9a4d8 fix: workover of the monaco update 2026-07-02 14:14:31 +02:00
ef6c970654 chore: updated monaco and tools to update monaco 2026-07-02 12:50:03 +02:00
e7d5cba01b chore: mark downloaded JS files as such for gitea 2026-07-02 10:27:57 +02:00
82c69a2c35 fix: close language picker on translation quick action 2026-06-30 22:33:05 +02:00
b11be9b521 fix: quick action translate and validate apply now use tasks 2026-06-30 22:28:05 +02:00
1b0bae37bf fix: update sidebar on post changes (status like publishing) 2026-06-30 22:02:06 +02:00
c2b2b1ff11 fix: fixed tag suggestions 2026-06-30 21:48:15 +02:00
5f77641f3f fix: monaco editor glitches fixed 2026-06-30 21:24:08 +02:00
ba9634c478 fix: fixed bds2: protocol handling 2026-06-30 21:07:26 +02:00
49675a49d2 chore: removed old TODO 2026-06-26 16:34:25 +02:00
d1acfd4d71 fix: reworking some code smell issues 2026-06-26 16:34:07 +02:00
68de265137 chore: removed old todo 2026-06-25 15:51:22 +02:00
57bdcef990 fix: more refactorings for cleaning code 2026-06-25 15:50:08 +02:00
52809b39b1 fix: part of refactoring 2026-06-25 15:42:42 +02:00
e3e94ac2d9 fix: refactorings to remove anti-patterns 2026-06-25 15:42:32 +02:00
382 changed files with 30215 additions and 10758 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Downloaded JS — exclude from language detection
priv/**/*.js linguist-vendored=true

View File

@@ -30,6 +30,7 @@ This document provides context and best practices for GitHub Copilot when workin
- 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, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits and clean dialyzer results - you MUST run build, test, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits 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 - on a headless Linux machine, you have to run tests with this command (if mix test complains about DISPLAX): xvfb-run mix test
- CSS precedence: Tailwind utilities and the ui-* kit (utilities.css) are in @layer and ALWAYS lose to the unlayered hand-written files (shell.css, panel.css, ...). Convention: hand-written CSS owns appearance, Tailwind utilities in templates own layout — never put display/flex-direction/size rules in hand-written CSS for elements whose layout the template controls, and define each selector in exactly one file.
--- ---
@@ -55,6 +56,15 @@ This document provides context and best practices for GitHub Copilot when workin
- Never leave tests failing, even if they appear unrelated to your changes - Never leave tests failing, even if they appear unrelated to your changes
- If a test failure is pre-existing, fix it as part of your current work - If a test failure is pre-existing, fix it as part of your current work
- **Always write the full test run to a log file first, then grep that file for failures.** Never run `mix test` in the terminal and try to grep live output — the output is interleaved, truncated, and nearly impossible to parse. Pattern:
```
mix test 2>&1 > /tmp/test_run.log
grep -E "FAILED|^[ ]*1\)" /tmp/test_run.log
grep -A 15 "^[ ]*1\)" /tmp/test_run.log # details for the first failure
```
Work from the log file, not from repeated terminal runs.
> **Zero failing tests. No exceptions.**
- Run the full test suite (`mix test`) before considering any task complete - Run the full test suite (`mix test`) before considering any task complete
- If you cannot fix a test, explain why and propose a solution - If you cannot fix a test, explain why and propose a solution
@@ -115,6 +125,34 @@ This document provides context and best practices for GitHub Copilot when workin
--- ---
## ⚠️ MANDATORY: macOS Bundle Build Order and Blogmark Verification
**`mix release` copies `priv/static` as-is — it never rebuilds assets.** Always build in this order, or the bundle ships stale JS while the server code is current (this silently broke blogmark deep links in July 2026: the server waited for the client's `shell_ready` event that the stale `app.js` never sent, so every deep link queued forever with no error, no toast, no log):
```
mix assets.deploy
MIX_ENV=prod mix release bds --overwrite
mix bds.bundle.macos
```
**After building a bundle, verify blogmark deep links end-to-end with:**
```
scripts/verify_blogmark_macos.sh [path/to/BDS2.app] # defaults to dist/macos/BDS2.app
```
The script launches the bundle via LaunchServices with an isolated database (`launchctl setenv BDS_DATABASE_PATH ...`), fires `bds2://new-post` links with `open -a` exactly like a browser bookmarklet, and asserts draft posts appear in the database — both for cold start (link launches the app, queued until the shell attaches) and for an already-running app. It refuses to run while any BDS2 instance is up; quit the app first.
Things learned the hard way, encoded in the script — do not "simplify" them away:
- Deep links only reach an app **launched through LaunchServices** (`open -a`). A process started by exec'ing `Contents/MacOS/bds2` from a shell never receives Apple Events, so deep links vanish without a trace. For the same reason `open` does not inherit the shell environment — inject env vars with `launchctl setenv` (and unset them afterwards).
- To introspect a running bundle, launch it with `RELEASE_DISTRIBUTION=name` and `RELEASE_NODE=bds@127.0.0.1` set (via `launchctl setenv`), then use `.../Contents/Resources/rel/bin/bds rpc '<elixir>'` with the same env. The default `-sname` node is unreachable when the hostname resolves to LAN addresses.
- The wx → `Desktop.Env` → `BDS.Desktop.DeepLink` → shell pipeline downstream of the OS can be exercised without LaunchServices via `send(Process.whereis(BDS.Desktop.DeepLink), {:open_url, ~c"bds2://new-post?..."})` over rpc.
> **Assets before release. Verify blogmarks after bundling. No exceptions.**
---
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text ## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
**All user-facing text MUST follow proper i18n patterns.** **All user-facing text MUST follow proper i18n patterns.**

29
API.md
View File

@@ -40,6 +40,7 @@ local meta = bds.meta.get_project_metadata()
- [app.get_default_project_path](#appget_default_project_path) - [app.get_default_project_path](#appget_default_project_path)
- [app.get_system_language](#appget_system_language) - [app.get_system_language](#appget_system_language)
- [app.get_title_bar_metrics](#appget_title_bar_metrics) - [app.get_title_bar_metrics](#appget_title_bar_metrics)
- [app.log](#applog)
- [app.notify_renderer_ready](#appnotify_renderer_ready) - [app.notify_renderer_ready](#appnotify_renderer_ready)
- [app.open_folder](#appopen_folder) - [app.open_folder](#appopen_folder)
- [app.read_project_metadata](#appread_project_metadata) - [app.read_project_metadata](#appread_project_metadata)
@@ -150,7 +151,7 @@ local result = bds.app.get_default_project_path()
### app.get_system_language ### app.get_system_language
Return the current UI locale. Return the current UI locale (the server-side UI language setting, falling back to the OS locale when unset).
**Parameters** **Parameters**
@@ -202,6 +203,30 @@ nil -- or
local result = bds.app.get_title_bar_metrics() local result = bds.app.get_title_bar_metrics()
``` ```
### app.log
Append a line to the script output stream. Multiple arguments are joined with spaces. Output appears in the desktop app's Output panel (and on stdout in the CLI); Lua's global `print` is routed the same way.
**Parameters**
- text (string, required)
**Response specification**
- Return type: `boolean`
**Example response**
```lua
true
```
**Example call**
```lua
local result = bds.app.log("value")
```
### app.notify_renderer_ready ### app.notify_renderer_ready
Notify the host application that the renderer is ready. Notify the host application that the renderer is ready.
@@ -3443,7 +3468,7 @@ local result = bds.meta.sync_on_startup()
### meta.update_project_metadata ### meta.update_project_metadata
Update metadata for the current project. Update metadata for the current project. Keys omitted from updates keep their current values.
**Parameters** **Parameters**

View File

@@ -23,6 +23,7 @@
- [Generating and publishing](#generating-and-publishing) - [Generating and publishing](#generating-and-publishing)
- [Typical editorial workflows](#typical-editorial-workflows) - [Typical editorial workflows](#typical-editorial-workflows)
- [Working fully offline](#working-fully-offline) - [Working fully offline](#working-fully-offline)
- [Running a headless server and the terminal UI](#running-a-headless-server-and-the-terminal-ui)
- [Troubleshooting and recovery](#troubleshooting-and-recovery) - [Troubleshooting and recovery](#troubleshooting-and-recovery)
- [Team conventions](#team-conventions) - [Team conventions](#team-conventions)
@@ -443,6 +444,29 @@ When AI is involved, airplane mode determines which automatic actions are allowe
--- ---
## Running a headless server and the terminal UI
bDS2 can run without a window on a server (for example a Linux VPS) and be used from several places at once. Start it with `BDS_MODE=server`; the same release binary that runs the desktop app then runs headless. All clients connect through one SSH port (default 2222) — the web endpoint itself stays private on the server.
Access is controlled by SSH keys, not passwords. On first start the server creates an `ssh/` folder next to its database (for example `~/Library/Application Support/BDS2/ssh/` on macOS) containing the host key and an empty `authorized_keys` file. Paste the public keys of every machine that may connect into `authorized_keys`, one per line — the same file format OpenSSH uses.
To work in a terminal, connect with plain `ssh -p 2222 user@server`: the terminal UI opens directly. You can browse posts, media, templates, scripts, and tags, create and edit posts, publish, preview images, and run the one-shot AI actions. The status line at the bottom always shows the available keys.
To work in the desktop app against a remote server, use **File → Connect to Server…** and enter `user@host` (or `user@host:port`). The app connects with the SSH key from its own local `ssh/` folder and shows the remote workspace in the window; **File → Disconnect from Server** returns to the local workspace. A plain browser works too, through a manual SSH tunnel (`ssh -p 2222 -L 4010:127.0.0.1:4010 user@server`, then open `http://127.0.0.1:4010`).
Everything you see stays synchronized: when a script, another client, or a pipeline changes content on the server, every connected terminal and window updates. The interface language is a server-side setting — changing it in any client changes it for all of them.
### Key takeaways
- `BDS_MODE=server` runs the same app headless; only the SSH port is exposed.
- Access is public-key only: manage `authorized_keys` in the server's `ssh/` folder.
- `ssh` gives the terminal UI; **File → Connect to Server…** gives the desktop app; both use the same keys.
- All connected clients stay synchronized and share one interface language.
[↑ Back to In this article](#in-this-article)
---
## Troubleshooting and recovery ## Troubleshooting and recovery
If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote. If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote.

View File

@@ -12,6 +12,7 @@ The major architectural rework is in place.
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static). - Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP. - Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model. - Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
- The app boots in three modes: the desktop app, a headless server (SSH-served terminal UI + tunneled GUI), and a local TUI — see [Headless Server Mode And Terminal UI](#headless-server-mode-and-terminal-ui).
The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices. The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
@@ -36,7 +37,7 @@ If you are tracing UI behavior, start there first:
- LiveView event routing, workbench state, overlays, and menu handling live in the desktop shell modules. - LiveView event routing, workbench state, overlays, and menu handling live in the desktop shell modules.
- HEEx templates under the same tree now own most common layout and state styling. - HEEx templates under the same tree now own most common layout and state styling.
- Monaco remains a vendor drop under [priv/ui/monaco/](/Users/gb/Projects/bDS2/priv/ui/monaco). - Monaco is bundled as an ESM bundle via esbuild into `priv/static/assets/monaco.js` and `monaco.css` (source entry at `assets/js/monaco_entry.js`).
### Domain Modules ### Domain Modules
@@ -156,6 +157,84 @@ mix dialyzer
mix assets.build mix assets.build
``` ```
## Headless Server Mode And Terminal UI
Since issue #26 the app has three boot modes, selected with the `BDS_MODE`
environment variable (any release or dev boot; default is the desktop app):
- `desktop` — the native desktop app, unchanged.
- `server` — headless: no window, loopback-only HTTP endpoint, CLI sync
watcher, and an SSH daemon serving the terminal UI. Works on macOS,
Windows, and Linux (no display needed).
- `tui` — the headless server plus a TUI attached to the launching terminal.
### Build and run a headless server
```bash
MIX_ENV=prod mix release bds --overwrite
BDS_MODE=server _build/prod/rel/bds/bin/bds start
```
The SSH daemon listens on port `2222` (`BDS_SSH_PORT` or
`config :bds, :server, ssh_port:` to change). Authentication is public-key
only: on first boot the server generates a host key and an empty
`authorized_keys` (mode 600) in `ssh/` next to the database (default
`~/Library/Application Support/BDS2/ssh/`, or under `BDS_DATABASE_PATH`).
Add your public key there, then connect from any terminal:
```bash
ssh -p 2222 <server-host> # opens the TUI
```
Each SSH connection gets its own TUI session on the server; only terminal
cells cross the wire. All clients stay synchronized through the domain
event bus (`BDS.Events`) — a post created by a pipeline or another client
appears in every open shell.
### TUI keys
`↑/↓` or `j/k` navigate · `enter` open · `n` new post · `1-5` switch view
(posts/media/templates/scripts/tags) · `:` command prompt (metadata
diff, validate site, force render, rebuilds, …; `:?` lists all) ·
metadata-diff/site-validation reports open as panels — `enter` applies
the whole report (repair from files / incremental apply), `esc` cancels ·
`r` refresh · in the editor:
`ctrl+s` save, `ctrl+p` publish, `ctrl+e` word-wrapped preview,
`ctrl+t` title, `ctrl+l` language, `ctrl+g` AI suggestions
(airplane-mode gated), `esc` back · `ctrl+q` quit.
Images preview inline via the terminal image protocol. The UI language is
the server-side setting shared by every client.
### Remote GUI
The HTTP endpoint stays bound to `127.0.0.1` and is never exposed; GUI
clients tunnel through the same SSH daemon and keys:
```bash
ssh -p 2222 -L 4010:127.0.0.1:4010 -N <server-host> &
open http://127.0.0.1:4010
```
Or use the desktop app directly: **File → Connect to Server…** takes
`user@host[:port]`, connects with the client key from the same local
`ssh/` directory (public-key auth, trust-on-first-use `known_hosts`),
tunnels to the server, and points the window at it. **File → Disconnect
from Server** returns to the local workspace.
## Monaco Editor
Monaco is bundled via esbuild using its ESM entry point (`monaco-editor/esm/vs/editor/editor.main.js`). The
main bundle is built as part of `mix assets.build` and lives at `priv/static/assets/monaco.js` and
`monaco.css`; ESM worker bundles live under `priv/static/assets/monaco/`.
```bash
mix monaco.version # show current monaco-editor version
mix monaco.update 0.55.1 # update to a specific version and rebuild the bundle
```
The update task runs `npm install monaco-editor@<version>`, cleans the old bundle, and rebuilds via esbuild.
See https://www.npmjs.com/package/monaco-editor for available versions.
Notes for developers: Notes for developers:
- Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior. - Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.

223
TUI_EDITOR.md Normal file
View File

@@ -0,0 +1,223 @@
# TUI Editor — Implementation Plan (Issue #32)
Tracking issue: **#32** — "markdown, liquid and lua code editor with syntax
highlighting in the TUI".
Spec under change: `specs/tui.allium`.
Goal: a syntax-highlighted, word-wrapping TUI editor for posts (markdown
with `[[macro]]` syntax), templates (HTML + Liquid), and scripts (Lua),
replacing the current plain `ExRatatui.Widgets.Textarea` body.
## Decision: pure Elixir — no new Rust
`ExRatatui` already ships everything we need:
- `ExRatatui.CodeBlock.highlight/3` (`deps/ex_ratatui/lib/ex_ratatui/code_block.ex:103`)
returns `[%ExRatatui.Text.Line{}]` of styled spans via the bundled
syntect NIF. Supports Lua, Markdown, HTML, … out of the box.
- `ExRatatui.Widgets.CodeBlock` is read-only; we do **not** use it
directly for editing.
- `ExRatatui.Widget` protocol (`deps/ex_ratatui/lib/ex_ratatui/widget.ex:1`)
lets us implement a custom widget in pure Elixir that composes
primitives every frame. This is the seam we use.
- The editing engine stays Rust-owned
(`ExRatatui.textarea_new/0`, `textarea_handle_key/3`, `textarea_get_value/1`,
`textarea_cursor/1`, `textarea_line_count/1`). The custom widget only
re-renders the buffer through a styling + wrap pipeline.
- `ExRatatui.Widgets.Paragraph` accepts `[%Text.Line{}]` of styled spans
with `wrap: true` and a `scroll: {vertical, horizontal}` offset —
exactly the output shape we need.
The existing comment at `lib/bds/tui.ex:1451-1454` already pre-announces
this approach as "the planned MarkdownEditor custom widget".
Languages supported (matches GUI Monaco registrations in
`assets/js/monaco/languages.js`):
| Entity | Base lexer (NIF) | Elixir overlay |
|-----------------------|------------------|---------------------------------|
| post body | `markdown` | `[[macro …]]` recoloured |
| script body | `lua` | none |
| template body | `html` | `{{ … }}` and `{% … %}` recoloured |
Liquid is always HTML+Liquid in this codebase (no markdown+Liquid
templates exist). Plain text falls back to syntect's plain renderer.
## GUI styling parity (from `assets/js/monaco/theme.js` + `languages.js`)
- **macro** `[[ … ]]``keyword.macro` = `#C586C0`, **bold**.
- inside macro: `attribute.name` (e.g. `names`) → `#9CDCFE`,
`attribute.value` (e.g. `"x"`) → `#CE9178`.
- Liquid uses base vs-dark tokens; terminal approximation uses the same
base theme accent colours. No custom Liquid token colours overridden.
Use `{:rgb,197,134,192}` etc. via `ExRatatui.Style` (already supported
by `ExRatatui.CodeBlock.from_native/1`).
## Architecture
```
state.editor.textarea (unchanged, Rust-owned buffer/cursor/undo)
state.editor.language (:markdown_macros | :liquid | :lua | :text)
state.editor.preview? (unchanged ctrl+e toggle — default false for posts)
body_widget/3 edit branch: %BDS.UI.CodeEditor{textarea:, language:, theme:, wrap:, cursor_style:, line_highlight_style:, block:}
↳ impl ExRatatui.Widget.render:
1. content = ExRatatui.textarea_get_value(textarea)
2. {row, col} = ExRatatui.textarea_cursor(textarea)
3. lines = BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)
4. apply current-line bg style into every span on lines[row]
5. cut the span at byte col on lines[row], splice cursor-style cell
6. top = vertical-scroll offset so cursor visual row stays in window (Latin-width count)
7. return [{%Paragraph{text: lines, wrap: true, scroll: {top,0}, block: block}, rect}]
```
`editor_key/1` in `lib/bds/tui.ex` keeps calling
`ExRatatui.textarea_handle_key/3` — no edit-model rewrite, the
textarea's own undo/redo/clipboard keeps working.
## Phases (test-first, per AGENTS.md)
### Phase 0 — Spec
Edit `specs/tui.allium`:
- `OpenEntry`: posts open in the **editor** (wrap on, highlighting on)
by default — not the rendered Markdown preview. New empty posts still
open in the editor (unchanged).
- Rename `WrappedPreview``EditorMode`: `ctrl+e` toggles editor ↔
read-only rendered-Markdown preview (via `ExRatatui.Widgets.Markdown`).
Drop the "textarea cannot wrap" caveat.
- Add `CodeEditorWrapping` rule: syntax highlighting + auto wrap +
highlighted current line. Explicitly **no line-number gutter**.
- Validate with `allium check specs/tui.allium`.
### Phase 1 — Red tests
New file `test/bds/ui/code_editor_test.exs`:
- `BDS.UI.CodeEditor.Highlight.highlight/3` for `:lua` colours a
`function … end` keyword with the theme's keyword colour.
- `:markdown_macros` recolours `[[gallery names="x"]]`:
- `[[`, `]]` and macro name → `{:rgb,197,134,192}` bold
- `names``{:rgb,156,220,254}`
- `"x"``{:rgb,206,145,120}`
- surrounding markdown still base-styled.
- `:liquid` recolours `{{ x }}` and `{% if x %}` over an HTML base;
`if` keyword distinct from identifiers.
- Widget emits a `%Paragraph{wrap: true, scroll:}`, never a bare `%Textarea`,
when language is one of the highlighted set.
Extended `test/bds/tui_test.exs`:
- Opening an existing post lands in the **editor** (`preview? == false`),
not the rendered preview. Replace the assertion in
`test "opening a post lands in the markdown preview, not the editor"`.
Test name/logic flips.
- Long line wraps across N visual rows in a 40-wide viewport
(assert via `CellSession.take_cells/1`).
- Cursor at the last visual row stays visible when scrolling a small
viewport (assert cursor-style cell appears in snapshot cells).
- Current line's cells have a distinct `bg` from non-current rows on
the same buffer.
- `ctrl+e` flips to preview and back; preview still rendered Markdown.
- `ctrl+s` persists textarea bytes unchanged regardless of highlight/wrap
(existing assertion still covers this — keep it green).
### Phase 2 — `lib/bds/ui/code_editor/highlight.ex` + `overlay.ex`
- `BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)`
`[%ExRatatui.Text.Line{}]`:
- `:lua`, `:text` → pass straight through to
`ExRatatui.CodeBlock.highlight/3`.
- `:markdown_macros` → base highlight as `"markdown"`, then apply
`Overlay.markdown_macros/1` per source line.
- `:liquid` → base highlight as `"html"`, then apply
`Overlay.liquid/1` per source line.
- `BDS.UI.CodeEditor.Overlay`:
- `apply_ranges(line, ranges_with_styles)` — re-splits a `Line`'s
spans into a new span list, preserving the base styling outside
each range.
- `markdown_macro_ranges/1``~r/\[\[\s*[a-zA-Z][\w-]*[^]]*\]\]/s`.
- `macro_inner_ranges/1` — break `attr=` (attr-name) and quoted value
into separate styled sub-ranges inside a macro.
- `liquid_ranges/1``~r/(\{\{.*?\}\}|\{%-?\s*(\w+).*?-?%\})/s`,
with a sub-range for the tag keyword (`if`, `for`, `assign`, …).
- Cache results by `{content_hash, language, theme}` if profiling shows
re-tokenisation overhead on keystroke (Phase 2 stretch goal —
skip until a test says otherwise).
### Phase 3 — `lib/bds/ui/code_editor/widget.ex`
- `%BDS.UI.CodeEditor{}` struct fields: `textarea`, `language`,
`theme` (default `:base16_ocean_dark`, readable in dark terminals),
`wrap: true`, `cursor_style`, `line_highlight_style`, `block`, `scroll`.
- `defimpl ExRatatui.Widget` per the pipeline above.
- Cursor cell: re-style the single codepoint at `col` on source line
`row` into `cursor_style`. Paragraph wraps the same line; the styled
cell rides the wrap.
- Scroll math: count display rows by wrapping each source line into
`width` columns (Latin-width assumption — 1 char = 1 cell). Note the
limitation explicitly; CJK width is a follow-up if/when needed.
- Line highlight: apply `line_highlight_style` (a `bg:` colour) to
every span on the cursor's source line before cursor cut-in.
### Phase 4 — Wire into `lib/bds/tui.ex`
- `build_editor/4` (around `lib/bds/tui.ex:1968`): default
`preview? = false` for existing posts. New posts already open in
editor — unchanged.
- `body_widget/3` edit branch (`lib/bds/tui.ex:1466`): emit
`%BDS.UI.CodeEditor{}` instead of `%ExRatatui.Widgets.Textarea{}` for
entities that have a syntax highlighter (`:lua`, `:markdown_macros`,
`:liquid`). Plain text can keep `%Textarea{}` or feed `:text` through
the new widget — pick whichever keeps the test surface smaller.
- Remove the "wrap limitation" comment block at
`lib/bds/tui.ex:1451-1454`.
- `cycle_language/1` continues per post; cycle through the active
set (`:markdown_macros`, `:liquid`, `:lua`, `:text`) matching the
existing GUI editor language options.
- Preview branch (`body_widget/3` preview arm at `lib/bds/tui.ex:1455`)
stays on `ExRatatui.Widgets.Markdown` — unchanged.
- No new user-facing strings: the existing body title already flows
through `dgettext("ui", …)`. If a new toolbar/hint is added later,
it MUST go through gettext and provide de/fr/it/es translations.
### Phase 5 — Verify
- `mix test` (write to `/tmp/test_run.log` first, grep for failures —
per AGENTS.md).
- `mix credo --strict`
- `mix deps.audit --ignore-file .mix_audit.ignore`
- `mix dialyzer` — treat warnings as errors.
No bundle rebuild, no asset change, no NIF rebuild. Monaco still drives
the GUI editor; this change is TUI-only.
## Risks / limits
- **Per-frame NIF cost:** re-tokenises the whole buffer on every
keystroke. For typical post sizes (<50 KB) syntect is sub-ms. Add the
`{content_hash, language, theme}` cache only if a test shows it.
- **CJK cell width:** wrap math assumes 1 char = 1 cell. Fine for
de/fr/it/es. Note as TODO; not in scope for this phase.
- **Liquid mis-lexing inside HTML** is cosmetically possible (an HTML
attribute containing `{{`). Overlay wins after the base lexer; the
result is visually tolerable. A custom `.sublime-syntax` in the
dep would be the real fix — out of scope here per "stay on Elixir".
- **Cursor in a wrapped paragraph** rides the styled cell. Equivalent
to a cell-caret; GUI uses a real caret — acceptable TUI parity.
## Files touched (expected)
- `specs/tui.allium` — edited.
- `lib/bds/ui/code_editor/highlight.ex` — new.
- `lib/bds/ui/code_editor/overlay.ex` — new.
- `lib/bds/ui/code_editor/widget.ex` — new.
- `lib/bds/tui.ex` — edited (`build_editor`, `body_widget`, comment
removal, default `preview?`).
- `test/bds/ui/code_editor_test.exs` — new.
- `test/bds/tui_test.exs` — edited (flip the "opens in preview"
assertion, add wrap/cursor/current-line tests).
No changes to `mix.exs`, no new deps, no Rust, no assets.

View File

@@ -3,6 +3,7 @@
@source "../css"; @source "../css";
@source "../js"; @source "../js";
@source "../../lib/bds/desktop"; @source "../../lib/bds/desktop";
@source "../../deps/live_toast/lib";
@import "./tokens.css"; @import "./tokens.css";
@import "./shell.css"; @import "./shell.css";

View File

@@ -646,12 +646,6 @@
max-width: 22px; max-width: 22px;
max-height: 22px; max-height: 22px;
padding: 0; padding: 0;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.chat-panel .chat-send-button:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground);
} }
.chat-panel .chat-send-button:disabled { .chat-panel .chat-send-button:disabled {

View File

@@ -14,8 +14,7 @@
.editor-main, .editor-main,
.editor-meta, .editor-meta,
.panel-shell, .panel-shell {
.assistant-card {
min-width: 0; min-width: 0;
} }
@@ -50,8 +49,7 @@
border-radius: 3px; border-radius: 3px;
} }
.editor-toolbar-button:hover, .editor-toolbar-button:hover {
.panel-tab:hover {
background: var(--vscode-toolbar-hoverBackground); background: var(--vscode-toolbar-hoverBackground);
} }
@@ -617,6 +615,14 @@
white-space: nowrap; white-space: nowrap;
} }
.post-editor .tag-suggestion-section-label {
padding: 4px 12px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground, #a6a6a6);
}
.post-editor .tag-suggestion.create-new { .post-editor .tag-suggestion.create-new {
border-top: 1px solid var(--vscode-widget-border, #454545); border-top: 1px solid var(--vscode-widget-border, #454545);
margin-top: 4px; margin-top: 4px;
@@ -742,8 +748,7 @@
} }
.post-editor .editor-toolbar { .post-editor .editor-toolbar {
display: grid; display: flex;
grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
@@ -751,23 +756,27 @@
.post-editor .editor-toolbar-left { .post-editor .editor-toolbar-left {
display: flex; display: flex;
flex: 1;
min-width: 0;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
} }
.post-editor .editor-toolbar-center { .post-editor .editor-toolbar-center {
display: flex; display: flex;
flex: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* ponytail: right group keeps its content width so buttons never wrap or
overlap the mode toggle; the left label shrinks instead */
.post-editor .editor-toolbar-right { .post-editor .editor-toolbar-right {
display: flex; display: flex;
flex: 1 0 auto;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 6px; gap: 6px;
min-width: 0;
flex-wrap: wrap;
} }
.post-editor .editor-mode-toggle { .post-editor .editor-mode-toggle {
@@ -809,38 +818,6 @@
display: none; display: none;
} }
.post-editor .gallery-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .gallery-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .insert-post-link-button,
.post-editor .insert-media-button {
padding: 4px 8px;
font-size: 14px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .insert-post-link-button:hover,
.post-editor .insert-media-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .editor-preview { .post-editor .editor-preview {
flex: 1; flex: 1;
background-color: var(--vscode-input-background); background-color: var(--vscode-input-background);
@@ -929,8 +906,7 @@
.post-editor .editor-header, .post-editor .editor-header,
.scripts-view-shell .ui-editor-header, .scripts-view-shell .ui-editor-header,
.templates-view-shell .ui-editor-header, .templates-view-shell .ui-editor-header,
.post-editor .metadata-toggle-header, .post-editor .metadata-toggle-header {
.post-editor .editor-toolbar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -946,7 +922,6 @@
width: 100%; width: 100%;
} }
.post-editor .editor-toolbar-right,
.post-editor .ui-editor-actions, .post-editor .ui-editor-actions,
.scripts-view-shell .ui-editor-actions, .scripts-view-shell .ui-editor-actions,
.templates-view-shell .ui-editor-actions { .templates-view-shell .ui-editor-actions {

View File

@@ -67,30 +67,12 @@
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
} }
.import-analysis button,
.import-analysis select { .import-analysis select {
border: 1px solid var(--vscode-button-border, transparent); border: 1px solid var(--vscode-button-border, transparent);
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 12px;
} }
.import-analysis button {
background: var(--vscode-button-secondaryBackground, var(--vscode-button-background));
color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground));
padding: 8px 12px;
cursor: pointer;
}
.import-analysis button:hover:not(:disabled) {
background: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground));
}
.import-analyze-btn,
.import-execute-btn {
background: var(--vscode-button-background) !important;
color: var(--vscode-button-foreground) !important;
}
.import-analysis button:disabled { .import-analysis button:disabled {
opacity: 0.65; opacity: 0.65;
cursor: not-allowed; cursor: not-allowed;
@@ -110,7 +92,7 @@
width: 18px; width: 18px;
height: 18px; height: 18px;
border: 2px solid var(--vscode-descriptionForeground); border: 2px solid var(--vscode-descriptionForeground);
border-top-color: var(--vscode-button-background); border-top-color: var(--vscode-button-background, #0e639c);
border-radius: 50%; border-radius: 50%;
animation: import-spinner-rotate 0.8s linear infinite; animation: import-spinner-rotate 0.8s linear infinite;
flex-shrink: 0; flex-shrink: 0;

View File

@@ -106,8 +106,8 @@
} }
[data-testid="media-editor"] .media-preview-image img { [data-testid="media-editor"] .media-preview-image img {
width: 100%; max-width: 100%;
height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
border-radius: 4px; border-radius: 4px;
} }
@@ -128,20 +128,6 @@
align-items: center; align-items: center;
} }
[data-testid="media-editor"] .add-link-btn {
background: var(--vscode-button-secondaryBackground);
border: none;
color: var(--vscode-button-secondaryForeground);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
[data-testid="media-editor"] .add-link-btn:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
[data-testid="media-editor"] .post-picker { [data-testid="media-editor"] .post-picker {
background: var(--vscode-dropdown-background); background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border); border: 1px solid var(--vscode-dropdown-border);

View File

@@ -537,3 +537,8 @@
border: 1px solid var(--vscode-panel-border); border: 1px solid var(--vscode-panel-border);
border-radius: 4px; border-radius: 4px;
} }
.monaco-diff-editor-instance {
width: 100%;
height: 100%;
}

View File

@@ -19,6 +19,7 @@
.language-picker-modal-backdrop, .language-picker-modal-backdrop,
.confirm-delete-modal-backdrop, .confirm-delete-modal-backdrop,
.confirm-dialog-overlay, .confirm-dialog-overlay,
.git-run-backdrop,
.gallery-overlay, .gallery-overlay,
.lightbox-overlay { .lightbox-overlay {
position: fixed; position: fixed;
@@ -35,6 +36,7 @@
.language-picker-modal, .language-picker-modal,
.confirm-delete-modal, .confirm-delete-modal,
.confirm-dialog, .confirm-dialog,
.git-run-modal,
.gallery-overlay-content { .gallery-overlay-content {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -44,6 +46,76 @@
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
} }
.git-run-modal {
width: min(760px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.git-run-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #3c3c3c;
}
.git-run-title {
margin: 0;
font-size: 14px;
font-family: var(--font-mono, monospace);
}
.git-run-status {
font-size: 12px;
font-weight: 600;
}
.git-run-status.running {
color: #d7ba7d;
}
.git-run-status.ok {
color: #4ec9b0;
}
.git-run-status.fail {
color: #f48771;
}
.git-run-output {
margin: 0;
padding: 12px 16px;
overflow: auto;
flex: 1;
min-height: 160px;
max-height: 60vh;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, monospace);
font-size: 12px;
line-height: 1.5;
background: #141414;
}
.git-run-stdout {
color: #d4d4d4;
}
.git-run-stderr {
color: #f48771;
}
.git-run-footer {
display: flex;
justify-content: flex-end;
padding: 12px 16px;
border-top: 1px solid #3c3c3c;
}
.ai-suggestions-modal, .ai-suggestions-modal,
.language-picker-modal, .language-picker-modal,
.confirm-delete-modal, .confirm-delete-modal,
@@ -257,8 +329,7 @@
} }
.ai-suggestions-modal-footer, .ai-suggestions-modal-footer,
.confirm-delete-modal-footer, .confirm-delete-modal-footer {
.confirm-dialog-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 10px; gap: 10px;
@@ -266,42 +337,12 @@
border-top: 1px solid #3c3c3c; border-top: 1px solid #3c3c3c;
} }
.button-cancel,
.button-delete,
.button-apply,
.confirm-dialog-actions button,
.insert-modal-submit,
.language-picker-row, .language-picker-row,
.shared-popover-entry, .shared-popover-entry,
.colour-swatch { .colour-swatch {
cursor: pointer; cursor: pointer;
} }
.button-cancel,
.confirm-dialog-actions button,
.insert-modal-submit {
border: 1px solid #4c4c4c;
border-radius: 4px;
padding: 8px 14px;
background: transparent;
color: #f0f0f0;
}
.button-apply,
.confirm-dialog-actions .primary,
.insert-modal-submit {
background: #0e639c;
border-color: #0e639c;
}
.button-delete {
border: none;
border-radius: 4px;
padding: 8px 14px;
background: #c73c3c;
color: #ffffff;
}
.insert-modal-tabs { .insert-modal-tabs {
display: flex; display: flex;
margin: 0 -20px; margin: 0 -20px;
@@ -336,3 +377,78 @@
font: inherit; font: inherit;
} }
.insert-modal-results {
overflow: auto;
padding: 8px 0;
}
.insert-modal-result-item {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
border: none;
background: transparent;
color: inherit;
padding: 10px 20px;
text-align: left;
cursor: pointer;
font: inherit;
}
.insert-modal-result-item:hover {
background: #252526;
}
.insert-modal-result-title {
color: #ffffff;
font-weight: 600;
}
.insert-modal-result-meta {
color: #9d9d9d;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.insert-modal-status {
padding: 16px 20px;
color: #9d9d9d;
}
.insert-modal-external {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px;
}
.insert-modal-external .insert-modal-input {
padding: 8px 10px;
border: 1px solid #3c3c3c;
border-radius: 4px;
background: #252526;
}
.insert-modal-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.insert-modal-label {
color: #9d9d9d;
font-size: 12px;
}
.insert-modal-submit {
align-self: flex-end;
}
/* Alert modal (errors) — reuses .confirm-delete-modal with a tone accent. */
.alert-modal-error {
border-top: 3px solid #ff6b6b;
}

View File

@@ -8,14 +8,11 @@
display: none; display: none;
} }
.panel-tabs {
display: flex;
gap: 8px;
}
.panel-tabs { .panel-tabs {
display: flex; display: flex;
gap: 2px; gap: 2px;
align-items: stretch;
height: 100%;
} }
.panel-tab { .panel-tab {
@@ -116,7 +113,6 @@
} }
.assistant-sidebar-prompt-form, .assistant-sidebar-prompt-form,
.assistant-sidebar-welcome,
.assistant-sidebar-transcript { .assistant-sidebar-transcript {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -142,12 +138,6 @@
.assistant-sidebar-start-button { .assistant-sidebar-start-button {
align-self: flex-start; align-self: flex-start;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 999px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
padding: 7px 14px;
cursor: pointer;
} }
.assistant-sidebar-start-button:disabled { .assistant-sidebar-start-button:disabled {
@@ -155,7 +145,6 @@
opacity: 0.55; opacity: 0.55;
} }
.assistant-card,
.assistant-sidebar-message { .assistant-sidebar-message {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -254,6 +243,12 @@
background-color: var(--vscode-sideBar-background); background-color: var(--vscode-sideBar-background);
} }
/* .panel-entry (shell.css) forces flex-direction: column and, being unlayered,
beats the Tailwind row utilities on this button — force the row here. */
.task-group-toggle {
flex-direction: row;
}
.output-entry { .output-entry {
padding: 8px; padding: 8px;
border-bottom: none; border-bottom: none;

View File

@@ -402,24 +402,6 @@
border-bottom: 1px solid var(--vscode-panel-border); border-bottom: 1px solid var(--vscode-panel-border);
} }
.panel-tabs {
display: flex;
align-items: stretch;
height: 100%;
}
.panel-tab {
border: none;
background: transparent;
color: var(--vscode-descriptionForeground);
padding: 0 12px;
cursor: pointer;
}
.panel-tab.active {
color: var(--vscode-tab-activeForeground);
}
.panel-close { .panel-close {
background: transparent; background: transparent;
border: none; border: none;
@@ -446,8 +428,7 @@
padding: 12px 14px; padding: 12px 14px;
} }
.panel-entry, .panel-entry {
.assistant-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
@@ -779,19 +760,6 @@
padding-left: 18px; padding-left: 18px;
} }
.editor-toolbar {
display: flex;
gap: 10px;
}
.editor-toolbar button {
padding: 9px 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-3);
color: var(--ink);
}
.editor-meta { .editor-meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -799,18 +767,7 @@
} }
.editor-meta-card, .editor-meta-card,
.assistant-card,
.panel-entry { .panel-entry {
padding: 16px; padding: 16px;
} }
.sidebar-header,
.assistant-header,
.panel-header {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}

View File

@@ -148,7 +148,6 @@
gap: 2px; gap: 2px;
} }
.assistant-card span,
.panel-entry span, .panel-entry span,
.editor-meta-row span, .editor-meta-row span,
.editor-subtitle, .editor-subtitle,
@@ -777,8 +776,8 @@
} }
.filter-chip.active { .filter-chip.active {
background-color: var(--vscode-button-background); background-color: var(--vscode-button-background, #0e639c);
color: var(--vscode-button-foreground); color: var(--vscode-button-foreground, #ffffff);
} }
.filter-chip.has-color { .filter-chip.has-color {

View File

@@ -37,6 +37,9 @@
--vscode-editorGroupHeader-tabsBorder: #1e1e1e; --vscode-editorGroupHeader-tabsBorder: #1e1e1e;
--vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31);
--vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31); --vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31);
--vscode-button-secondaryBackground: rgba(255, 255, 255, 0.08);
--vscode-button-secondaryForeground: #cccccc;
--vscode-button-secondaryHoverBackground: #4a4d51;
--vscode-foreground: #cccccc; --vscode-foreground: #cccccc;
--vscode-descriptionForeground: #858585; --vscode-descriptionForeground: #858585;
--vscode-panel-border: #80808059; --vscode-panel-border: #80808059;
@@ -130,12 +133,13 @@ button.compact {
} }
button.primary { button.primary {
background-color: var(--vscode-button-background); color: var(--vscode-button-foreground, #ffffff);
background-color: var(--vscode-button-background, #0e639c);
font-weight: 500; font-weight: 500;
} }
button.primary:hover { button.primary:hover {
background-color: var(--vscode-button-hoverBackground); background-color: var(--vscode-button-hoverBackground, #1177bb);
} }
button.success { button.success {

View File

@@ -19,6 +19,7 @@ import {
} from "../utils/shortcuts.js"; } from "../utils/shortcuts.js";
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js"; import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js"; import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
import { copyTextToClipboard } from "../utils/clipboard.js";
export const AppShell = { export const AppShell = {
mounted() { mounted() {
@@ -179,6 +180,10 @@ export const AppShell = {
} }
}); });
this.handleEvent("clipboard", ({ text }) => {
copyTextToClipboard(text || "");
});
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction); window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.addEventListener("keydown", this.handleShortcutKeyDown, true); window.addEventListener("keydown", this.handleShortcutKeyDown, true);
this.el.addEventListener("load", this.handleThumbnailLoad, true); this.el.addEventListener("load", this.handleThumbnailLoad, true);
@@ -186,6 +191,9 @@ export const AppShell = {
this.el.addEventListener("change", this.handleChange); this.el.addEventListener("change", this.handleChange);
syncMediaThumbnailState(this.el); syncMediaThumbnailState(this.el);
this.restoreStoredWorkbenchSession(); this.restoreStoredWorkbenchSession();
// Rehydration is done (any stored-session push is ordered before this):
// tell the server it may now deliver queued deep links.
this.pushEvent("shell_ready", {});
}, },
updated() { updated() {

View File

@@ -6,8 +6,10 @@ 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";
import { createLiveToastHook } from "live_toast";
export const Hooks = { export const Hooks = {
LiveToast: createLiveToastHook(),
AppShell, AppShell,
SidebarInteractions, SidebarInteractions,
SettingsSectionScroll, SettingsSectionScroll,

View File

@@ -5,6 +5,66 @@ import {
unregisterMonacoEditor unregisterMonacoEditor
} from "../monaco/services.js"; } from "../monaco/services.js";
const WEBKIT_TEXTAREA_ARROW_COMMANDS = {
ArrowLeft: "cursorLeft",
ArrowRight: "cursorRight",
ArrowUp: "cursorUp",
ArrowDown: "cursorDown"
};
const isWebKitEngine = () => {
const userAgent = globalThis.navigator?.userAgent || "";
return userAgent.includes("AppleWebKit") && !userAgent.includes("Chrome") && !userAgent.includes("Chromium");
};
const isMonacoInputArea = (target) =>
target instanceof HTMLTextAreaElement && target.classList.contains("inputarea");
export const webKitTextAreaArrowCommand = (event) => {
if (event?.altKey || event?.ctrlKey || event?.metaKey) {
return null;
}
const command = WEBKIT_TEXTAREA_ARROW_COMMANDS[event?.key];
if (!command) {
return null;
}
return event.shiftKey ? `${command}Select` : command;
};
// Applying a remote value via setValue resets the cursor to the buffer start,
// which then makes follow-up actions (like link inserts targeting the current
// selection) land at position 1,1. Capture and restore the selection so the
// cursor survives server-driven reconciles; Monaco clamps out-of-range
// positions to the new content.
export const applyRemoteEditorValue = (editor, value) => {
const selections = editor.getSelections ? editor.getSelections() : null;
editor.setValue(value);
if (selections && selections.length > 0 && editor.setSelections) {
editor.setSelections(selections);
}
};
export const bridgeWebKitTextAreaArrowKey = (editor, event) => {
if (!editor || !isMonacoInputArea(event.target)) {
return false;
}
const command = webKitTextAreaArrowCommand(event);
if (!command) {
return false;
}
event.preventDefault();
event.stopPropagation();
editor.trigger("keyboard", command, { source: "keyboard" });
return true;
};
export const MonacoEditor = { export const MonacoEditor = {
mounted() { mounted() {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
@@ -17,6 +77,10 @@ export const MonacoEditor = {
this.isApplyingRemoteUpdate = false; this.isApplyingRemoteUpdate = false;
this.lastKnownValue = this.textarea?.value || ""; this.lastKnownValue = this.textarea?.value || "";
this.handleWebKitTextAreaArrowKey = (event) => {
bridgeWebKitTextAreaArrowKey(this.editor, event);
};
this.syncEditorFromTextarea = () => { this.syncEditorFromTextarea = () => {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
@@ -26,11 +90,29 @@ export const MonacoEditor = {
const value = this.textarea.value || ""; const value = this.textarea.value || "";
// ponytail: ignore the server echoing back our own last-emitted value;
// only a genuinely remote value (translation switch, etc.) should reset
// the editor. Otherwise mid-typing echoes clobber the cursor to offset 0.
if (value === this.lastKnownValue) {
return;
}
// While the user is actively typing, Monaco owns the content. Never let a
// server re-render (autosave, dirty toggle, metadata) yank it out from
// under the cursor — reconcile only once the editor is blurred.
if (this.editor.hasTextFocus()) {
return;
}
if (this.editor.getValue() !== value) { if (this.editor.getValue() !== value) {
this.isApplyingRemoteUpdate = true; this.isApplyingRemoteUpdate = true;
this.editor.setValue(value);
try {
applyRemoteEditorValue(this.editor, value);
} finally {
this.isApplyingRemoteUpdate = false; this.isApplyingRemoteUpdate = false;
} }
}
this.lastKnownValue = value; this.lastKnownValue = value;
}; };
@@ -232,6 +314,10 @@ export const MonacoEditor = {
this.el.addEventListener("dragover", this.handleDragOver); this.el.addEventListener("dragover", this.handleDragOver);
this.el.addEventListener("drop", this.handleDrop); this.el.addEventListener("drop", this.handleDrop);
} }
if (isWebKitEngine()) {
this.host.addEventListener("keydown", this.handleWebKitTextAreaArrowKey, true);
}
}) })
.catch((error) => { .catch((error) => {
console.error("Failed to load Monaco editor", error); console.error("Failed to load Monaco editor", error);
@@ -273,6 +359,8 @@ export const MonacoEditor = {
this.el.removeEventListener("drop", this.handleDrop); this.el.removeEventListener("drop", this.handleDrop);
} }
this.host?.removeEventListener("keydown", this.handleWebKitTextAreaArrowKey, true);
unregisterMonacoEditor(this.editorId || this.el.id); unregisterMonacoEditor(this.editorId || this.el.id);
this.editor?.dispose(); this.editor?.dispose();
} }

View File

@@ -1,11 +1,54 @@
import { loadScript } from "../utils/script_loader.js";
import { ensureMonacoTheme } from "./theme.js"; import { ensureMonacoTheme } from "./theme.js";
import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js"; import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js";
let monacoLoaderPromise; let monacoLoaderPromise;
const monacoEditors = new Map(); const monacoEditors = new Map();
const monacoWorkerUrls = {
editor: "/assets/monaco/editor.worker.js",
css: "/assets/monaco/css.worker.js",
html: "/assets/monaco/html.worker.js",
json: "/assets/monaco/json.worker.js",
ts: "/assets/monaco/ts.worker.js"
};
const workerNameForLanguage = (label) => {
switch (label) {
case "css":
case "scss":
case "less":
return "css";
case "html":
case "handlebars":
case "razor":
return "html";
case "json":
return "json";
case "typescript":
case "javascript":
return "ts";
default:
return "editor";
}
};
const ensureMonacoEnvironment = () => {
if (globalThis.MonacoEnvironment?.getWorker) {
return;
}
globalThis.MonacoEnvironment = {
...globalThis.MonacoEnvironment,
getWorker(_workerId, label) {
const workerName = workerNameForLanguage(label);
return new Worker(monacoWorkerUrls[workerName], { name: label, type: "module" });
}
};
};
export const loadMonaco = () => { export const loadMonaco = () => {
ensureMonacoEnvironment();
if (window.monaco?.editor) { if (window.monaco?.editor) {
ensureMonacoTheme(window.monaco); ensureMonacoTheme(window.monaco);
registerLiquidLanguage(window.monaco); registerLiquidLanguage(window.monaco);
@@ -17,20 +60,17 @@ export const loadMonaco = () => {
return monacoLoaderPromise; return monacoLoaderPromise;
} }
monacoLoaderPromise = loadScript("/monaco/vs/loader.js") monacoLoaderPromise = import("/assets/monaco.js")
.then( .then((monaco) => {
() => window.monaco = monaco;
new Promise((resolve, reject) => { if (!monaco.editor) throw new Error("Monaco loaded but monaco.editor is missing");
window.require.config({ paths: { vs: "/monaco/vs" } }); ensureMonacoTheme(monaco);
window.require(["vs/editor/editor.main"], () => { registerLiquidLanguage(monaco);
ensureMonacoTheme(window.monaco); registerMarkdownWithMacrosLanguage(monaco);
registerLiquidLanguage(window.monaco); return monaco;
registerMarkdownWithMacrosLanguage(window.monaco);
resolve(window.monaco);
}, reject);
}) })
)
.catch((error) => { .catch((error) => {
console.error("Monaco load failed:", error);
monacoLoaderPromise = null; monacoLoaderPromise = null;
throw error; throw error;
}); });

View File

@@ -0,0 +1,3 @@
// ESM entry point for Monaco. Bundled by esbuild into priv/static/assets/monaco.js
// editor.main.js loads the API AND registers all bundled languages (api.js only exposes the API)
export * from "monaco-editor/esm/vs/editor/editor.main.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/css/css.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/editor/editor.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/html/html.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/json/json.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/typescript/ts.worker.js";

View File

@@ -0,0 +1,27 @@
// Copies text to the system clipboard, mirroring the old app's behavior:
// navigator.clipboard when available, with a hidden-textarea fallback for
// webviews that lack the async clipboard API.
export const copyTextToClipboard = async (text) => {
if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
await navigator.clipboard.writeText(text);
return;
}
if (typeof document === "undefined" || typeof document.createElement !== "function") {
return;
}
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.setAttribute("readonly", "");
textArea.style.position = "absolute";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
if (typeof document.execCommand === "function") {
document.execCommand("copy");
}
document.body.removeChild(textArea);
};

View File

@@ -1,33 +0,0 @@
export const loadScript = (src) =>
new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
if (existing.dataset.loaded === "true") {
resolve();
return;
}
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
once: true
});
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.addEventListener(
"load",
() => {
script.dataset.loaded = "true";
resolve();
},
{ once: true }
);
script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
once: true
});
document.head.appendChild(script);
});

View File

@@ -25,8 +25,13 @@ config :bds, :desktop,
window_min_size: {800, 600}, window_min_size: {800, 600},
title: "Blogging Desktop Server" title: "Blogging Desktop Server"
config :bds, :server, ssh_port: 2222
config :bds, BDS.Desktop.Endpoint, config :bds, BDS.Desktop.Endpoint,
url: [host: "127.0.0.1"], url: [host: "127.0.0.1"],
# Any-port loopback origins: the remote-GUI SSH tunnel terminates on an
# ephemeral local port, so the websocket origin port varies per connect.
check_origin: ["//127.0.0.1", "//localhost"],
adapter: Bandit.PhoenixAdapter, adapter: Bandit.PhoenixAdapter,
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false], render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
pubsub_server: BDS.PubSub, pubsub_server: BDS.PubSub,
@@ -55,6 +60,34 @@ config :esbuild,
--external:/images/* --external:/images/*
), ),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
],
monaco: [
cd: Path.expand("../assets", __DIR__),
args: ~w(
js/monaco_entry.js
--bundle
--target=es2022
--format=esm
--outfile=../priv/static/assets/monaco.js
--loader:.ttf=dataurl
),
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
],
monaco_workers: [
cd: Path.expand("../assets", __DIR__),
args: ~w(
js/monaco_workers/editor.worker.js
js/monaco_workers/css.worker.js
js/monaco_workers/html.worker.js
js/monaco_workers/json.worker.js
js/monaco_workers/ts.worker.js
--bundle
--target=es2022
--format=esm
--outdir=../priv/static/assets/monaco
--loader:.ttf=dataurl
),
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
] ]
config :bds, :scripting, config :bds, :scripting,

View File

@@ -1,5 +1,14 @@
import Config import Config
# Headless boots (BDS_MODE=server/tui, or desktop mode without a graphical
# display — issue #33) must neuter wx before the :desktop dependency
# application starts it and crashes the VM. Local tui mode additionally
# silences all other terminal writers (console logging goes to a file,
# model-download progress bars are disabled) — the TUI owns the terminal
# and every stray line scrolls its rendering up one row. Runtime config is
# the only hook that runs before dependency applications start.
BDS.Server.prepare_boot_env()
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") ||

View File

@@ -1,7 +1,15 @@
defmodule BDS.AI do defmodule BDS.AI do
@moduledoc """ @moduledoc """
Public interface for AI features — endpoint configuration, secret management, Stable public interface for AI features.
model catalog access, and dispatching chat and one-shot inference requests.
This facade owns the app-level AI state that other modules are expected to
depend on directly: endpoint configuration, secret management, airplane-mode
gating, and per-feature model preferences, alongside model catalog access and
dispatching chat and one-shot inference requests.
`BDS.AI.Catalog`, `BDS.AI.OneShot`, and `BDS.AI.Chat` are internal
implementation modules behind this boundary. Callers should treat `BDS.AI` as
the durable surface for AI behavior and configuration.
""" """
alias BDS.AI.Catalog alias BDS.AI.Catalog
@@ -46,13 +54,21 @@ defmodule BDS.AI do
model = MapUtils.attr(attrs, :model) model = MapUtils.attr(attrs, :model)
api_key = MapUtils.attr(attrs, :api_key) api_key = MapUtils.attr(attrs, :api_key)
with :ok <- put_setting("ai.#{kind_key}.url", url), with :ok <- put_or_delete_setting("ai.#{kind_key}.url", url),
:ok <- put_setting("ai.#{kind_key}.model", model), :ok <- put_or_delete_setting("ai.#{kind_key}.model", model),
:ok <- put_secret("ai.#{kind_key}.api_key", api_key, backend) do :ok <- put_or_delete_secret("ai.#{kind_key}.api_key", api_key, backend) do
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}} {:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
end end
end end
# A nil field means "clear it" — writing nil into put_setting/2 would raise a
# FunctionClauseError and crash the caller (the preferences panel).
defp put_or_delete_setting(key, nil), do: delete_setting(key)
defp put_or_delete_setting(key, value) when is_binary(value), do: put_setting(key, value)
defp put_or_delete_secret(key, nil, _backend), do: delete_setting(encrypted_key(key))
defp put_or_delete_secret(key, value, backend), do: put_secret(key, value, backend)
@spec get_endpoint(endpoint_kind(), keyword()) :: @spec get_endpoint(endpoint_kind(), keyword()) ::
{:ok, endpoint() | nil} | {:error, term()} {:ok, endpoint() | nil} | {:error, term()}
def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do

View File

@@ -1,5 +1,11 @@
defmodule BDS.AI.Chat do defmodule BDS.AI.Chat do
@moduledoc false @moduledoc """
Context for AI chat conversations: start, list, fetch, and delete
conversations, persist surface state, and resolve the effective chat model.
Automatic AI activity is gated by the app's airplane (offline) mode; callers
must respect that gating before invoking model-backed operations.
"""
import Ecto.Query import Ecto.Query
require Logger require Logger
@@ -9,6 +15,7 @@ defmodule BDS.AI.Chat do
alias BDS.AI.CatalogProvider alias BDS.AI.CatalogProvider
alias BDS.AI.ChatConversation alias BDS.AI.ChatConversation
alias BDS.AI.ChatMessage alias BDS.AI.ChatMessage
alias BDS.AI.ChatTitleGenerator
alias BDS.AI.ChatTools alias BDS.AI.ChatTools
alias BDS.AI.InFlight alias BDS.AI.InFlight
alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.OpenAICompatibleRuntime
@@ -24,8 +31,6 @@ defmodule BDS.AI.Chat do
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts." @default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
@default_max_output_tokens 16_384 @default_max_output_tokens 16_384
@title_max_output_tokens 256
@chat_title_max_length 30
@chat_max_tool_rounds 10 @chat_max_tool_rounds 10
@chat_await_timeout_margin_ms 5_000 @chat_await_timeout_margin_ms 5_000
@default_context_window 128_000 @default_context_window 128_000
@@ -34,7 +39,7 @@ defmodule BDS.AI.Chat do
def start_chat(attrs \\ %{}) when is_map(attrs) do def start_chat(attrs \\ %{}) when is_map(attrs) do
now = Persistence.now_ms() now = Persistence.now_ms()
model = MapUtils.attr(attrs, :model) model = MapUtils.attr(attrs, :model)
title = MapUtils.attr(attrs, :title) || generated_chat_title(model) title = MapUtils.attr(attrs, :title) || ChatTitleGenerator.generated_chat_title(model)
%ChatConversation{} %ChatConversation{}
|> ChatConversation.changeset(%{ |> ChatConversation.changeset(%{
@@ -430,11 +435,11 @@ defmodule BDS.AI.Chat do
conversation = Repo.get!(ChatConversation, conversation_id) conversation = Repo.get!(ChatConversation, conversation_id)
cond do cond do
chat_user_message_count(conversation_id) < 1 -> ChatTitleGenerator.chat_user_message_count(conversation_id) < 1 ->
Logger.debug("Chat title generation skipped reason=:no_user_messages") Logger.debug("Chat title generation skipped reason=:no_user_messages")
{:ok, reply} {:ok, reply}
not generated_chat_title?(conversation.title, conversation.model) -> not ChatTitleGenerator.generated_chat_title?(conversation.title, conversation.model) ->
Logger.debug( Logger.debug(
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}" "Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
) )
@@ -444,7 +449,7 @@ defmodule BDS.AI.Chat do
true -> true ->
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}") Logger.debug("Chat title generation requested conversation_id=#{conversation_id}")
case generate_chat_title(user_content, opts) do case ChatTitleGenerator.generate_chat_title(user_content, opts) do
{:ok, title} when is_binary(title) and title != "" -> {:ok, title} when is_binary(title) and title != "" ->
now = Persistence.now_ms() now = Persistence.now_ms()
@@ -465,87 +470,6 @@ defmodule BDS.AI.Chat do
end end
end end
defp generate_chat_title(user_content, opts) when is_binary(user_content) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
:ok <- Runtime.validate_target(:chat_title, model, mode),
request <- build_chat_title_request(user_content, model),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
title = sanitize_chat_title(Map.get(response, :content))
if title == "" do
Logger.warning("Chat title generation returned an empty title",
model: model,
content: inspect(Map.get(response, :content)),
usage: inspect(Map.get(response, :usage))
)
end
{:ok, title}
else
{:error, reason} = error ->
Logger.warning("Chat title generation failed", reason: inspect(reason))
error
other ->
Logger.warning("Chat title generation failed", reason: inspect(other))
other
end
end
defp build_chat_title_request(user_content, model) do
%{
operation: :chat_title,
model: model,
max_output_tokens: @title_max_output_tokens,
messages: [
%{
"role" => "system",
"content" =>
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
},
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
]
}
end
defp sanitize_chat_title(title) when is_binary(title) do
title =
title
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_leading("'")
|> String.trim_trailing("\"")
|> String.trim_trailing("'")
|> String.trim_trailing(".")
|> String.trim_trailing("!")
|> String.trim_trailing("?")
if String.length(title) > @chat_title_max_length do
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
else
title
end
end
defp sanitize_chat_title(_title), do: ""
defp chat_user_message_count(conversation_id) do
Repo.aggregate(
from(message in ChatMessage,
where: message.conversation_id == ^conversation_id and message.role == :user
),
:count,
:id
)
end
defp generated_chat_title?(title, model) do
title in [generated_chat_title(nil), generated_chat_title(model)]
end
defp chat_round( defp chat_round(
_conversation, _conversation,
_messages, _messages,
@@ -882,9 +806,6 @@ defmodule BDS.AI.Chat do
) )
end end
defp generated_chat_title(nil), do: "New Chat"
defp generated_chat_title(model), do: "Chat with #{model}"
defp load_chat_messages(conversation_id) do defp load_chat_messages(conversation_id) do
Repo.all( Repo.all(
from message in ChatMessage, from message in ChatMessage,
@@ -1020,5 +941,5 @@ defmodule BDS.AI.Chat do
defp encode_nullable(nil), do: nil defp encode_nullable(nil), do: nil
defp encode_nullable(value), do: Jason.encode!(value) defp encode_nullable(value), do: Jason.encode!(value)
defp blank?(value), do: value in [nil, ""] defp blank?(value), do: BDS.Values.blank?(value)
end end

View File

@@ -0,0 +1,99 @@
defmodule BDS.AI.ChatTitleGenerator do
@moduledoc false
require Logger
alias BDS.AI.ChatMessage
alias BDS.AI.OpenAICompatibleRuntime
alias BDS.AI.Runtime
alias BDS.Repo
import Ecto.Query
@title_max_output_tokens 256
@chat_title_max_length 30
def generate_chat_title(user_content, opts) when is_binary(user_content) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
:ok <- Runtime.validate_target(:chat_title, model, mode),
request <- build_chat_title_request(user_content, model),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
title = sanitize_chat_title(Map.get(response, :content))
if title == "" do
Logger.warning("Chat title generation returned an empty title",
model: model,
content: inspect(Map.get(response, :content)),
usage: inspect(Map.get(response, :usage))
)
end
{:ok, title}
else
{:error, reason} = error ->
Logger.warning("Chat title generation failed", reason: inspect(reason))
error
other ->
Logger.warning("Chat title generation failed", reason: inspect(other))
other
end
end
def build_chat_title_request(user_content, model) do
%{
operation: :chat_title,
model: model,
max_output_tokens: @title_max_output_tokens,
messages: [
%{
"role" => "system",
"content" =>
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
},
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
]
}
end
def sanitize_chat_title(title) when is_binary(title) do
title =
title
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_leading("'")
|> String.trim_trailing("\"")
|> String.trim_trailing("'")
|> String.trim_trailing(".")
|> String.trim_trailing("!")
|> String.trim_trailing("?")
if String.length(title) > @chat_title_max_length do
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
else
title
end
end
def sanitize_chat_title(_title), do: ""
def chat_user_message_count(conversation_id) do
Repo.aggregate(
from(message in ChatMessage,
where: message.conversation_id == ^conversation_id and message.role == :user
),
:count,
:id
)
end
def generated_chat_title?(title, model) do
title in [generated_chat_title(nil), generated_chat_title(model)]
end
def generated_chat_title(nil), do: "New Chat"
def generated_chat_title(model), do: "Chat with #{model}"
end

View File

@@ -0,0 +1,49 @@
defmodule BDS.AI.ChatToolQueryHelpers do
@moduledoc false
alias BDS.Search
def normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
def normalize_limit(_value), do: 10
def normalize_offset(value) when is_integer(value) and value >= 0, do: value
def normalize_offset(_value), do: 0
def search_filters(arguments) do
%{}
|> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"]))
|> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"]))
|> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"]))
|> BDS.MapUtils.maybe_put(
:missing_translation_language,
BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"])
)
|> BDS.MapUtils.maybe_put(:year, arguments["year"])
|> BDS.MapUtils.maybe_put(:month, arguments["month"])
|> BDS.MapUtils.maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|> Map.put(:limit, normalize_limit(arguments["limit"]))
end
def search_all_counted_posts(project_id, arguments) do
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
filters = Map.put(filters, :limit, max(total, 1))
{:ok, result} = Search.search_posts(project_id, "", filters)
result
end
def search_result(project_id, query, filters, mapper) do
{:ok, result} = Search.search_posts(project_id, query, filters)
%{
posts: Enum.map(result.posts, mapper),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end
end

View File

@@ -0,0 +1,350 @@
defmodule BDS.AI.ChatToolSchemas do
@moduledoc false
def tool_spec(name, description, parameters) do
%{
"type" => "function",
"function" => %{
"name" => name,
"description" => description,
"parameters" => parameters
}
}
end
def limit_schema do
%{
"type" => "object",
"properties" => %{
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}
}
}
end
def post_search_schema(require_query) do
schema = %{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string"},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"language" => %{"type" => "string"},
"missingTranslationLanguage" => %{"type" => "string"},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
"offset" => %{"type" => "integer", "minimum" => 0}
}
}
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
end
def count_posts_schema do
%{
"type" => "object",
"properties" => %{
"groupBy" => %{
"type" => "array",
"items" => %{
"type" => "string",
"enum" => ["year", "month", "tag", "category", "status"]
}
},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["groupBy"]
}
end
def post_id_schema do
%{
"type" => "object",
"properties" => %{"postId" => %{"type" => "string"}},
"required" => ["postId"]
}
end
def media_id_schema(extra_properties \\ %{}) do
%{
"type" => "object",
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
"required" => ["mediaId"]
}
end
def update_post_metadata_schema do
%{
"type" => "object",
"properties" => %{
"postId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"excerpt" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["postId"]
}
end
def update_media_metadata_schema do
%{
"type" => "object",
"properties" => %{
"mediaId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"alt" => %{"type" => "string"},
"caption" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["mediaId"]
}
end
def render_table_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional table title"},
"columns" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Column header names"
},
"rows" => %{
"type" => "array",
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows, each row is an array of cell values"
}
}
}
end
def render_chart_schema do
%{
"type" => "object",
"properties" => %{
"chartType" => %{
"type" => "string",
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
"description" =>
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
},
"title" => %{"type" => "string", "description" => "Optional chart title"},
"series" => %{
"type" => "array",
"description" => "Array of data points.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Data point label"},
"value" => %{"type" => "number", "description" => "Data point value"},
"segments" => %{
"type" => "array",
"description" =>
"Segments within this data point. Required for stacked-bar and heatmap charts.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "number"}
},
"required" => ["label", "value"]
}
}
},
"required" => ["label"]
}
}
},
"required" => ["chartType", "series"]
}
end
def render_form_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional form title"},
"fields" => %{
"type" => "array",
"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
def render_card_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Card title"},
"subtitle" => %{"type" => "string", "description" => "Optional subtitle"},
"body" => %{"type" => "string", "description" => "Card body text (supports markdown)"},
"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
def render_metric_schema do
%{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Metric label"},
"value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
}
}
end
def render_list_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional list title"},
"items" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "List items"
}
}
}
end
def render_tabs_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional tabs title"},
"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
def render_mindmap_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional mind map title"},
"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

@@ -4,6 +4,8 @@ defmodule BDS.AI.ChatTools do
import Ecto.Query import Ecto.Query
alias BDS.AI.Chat alias BDS.AI.Chat
alias BDS.AI.ChatToolQueryHelpers, as: QueryHelpers
alias BDS.AI.ChatToolSchemas, as: Schemas
alias BDS.Media, as: MediaContext alias BDS.Media, as: MediaContext
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.MCP.Queries alias BDS.MCP.Queries
@@ -12,7 +14,6 @@ defmodule BDS.AI.ChatTools do
alias BDS.Posts.PostMedia alias BDS.Posts.PostMedia
alias BDS.Projects.Project alias BDS.Projects.Project
alias BDS.Repo alias BDS.Repo
alias BDS.Search
@spec execute(String.t(), map(), String.t() | nil) :: map() @spec execute(String.t(), map(), String.t() | nil) :: map()
def execute("blog_stats", _arguments, project_id) do def execute("blog_stats", _arguments, project_id) do
@@ -58,8 +59,14 @@ defmodule BDS.AI.ChatTools do
def execute("search_posts", arguments, project_id) do def execute("search_posts", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
filters = search_filters(arguments) filters = QueryHelpers.search_filters(arguments)
search_result(project_id, arguments["query"] || "", filters, &Queries.post_summary/1)
QueryHelpers.search_result(
project_id,
arguments["query"] || "",
filters,
&Queries.post_summary/1
)
end end
def execute("read_post_by_slug", arguments, project_id) do def execute("read_post_by_slug", arguments, project_id) do
@@ -81,11 +88,11 @@ defmodule BDS.AI.ChatTools do
def execute("list_posts", arguments, project_id) do def execute("list_posts", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"]) limit = QueryHelpers.normalize_limit(arguments["limit"])
offset = normalize_offset(arguments["offset"]) offset = QueryHelpers.normalize_offset(arguments["offset"])
filters = search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset}) filters = QueryHelpers.search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
search_result(project_id, "", filters, fn post -> QueryHelpers.search_result(project_id, "", filters, fn post ->
post post
|> Queries.post_summary() |> Queries.post_summary()
|> Map.put("url", "/posts/#{post.slug}") |> Map.put("url", "/posts/#{post.slug}")
@@ -95,7 +102,7 @@ defmodule BDS.AI.ChatTools do
def execute("list_media", arguments, project_id) do def execute("list_media", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"]) limit = QueryHelpers.normalize_limit(arguments["limit"])
Repo.all( Repo.all(
from(media in Media, from(media in Media,
@@ -194,7 +201,7 @@ defmodule BDS.AI.ChatTools do
def execute("count_posts", arguments, project_id) do def execute("count_posts", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1) group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
result = search_all_counted_posts(project_id, arguments) result = QueryHelpers.search_all_counted_posts(project_id, arguments)
groups = groups =
result.posts result.posts
@@ -321,7 +328,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "blog_stats", name: "blog_stats",
spec: spec:
tool_spec("blog_stats", "Return aggregate blog statistics", %{ Schemas.tool_spec("blog_stats", "Return aggregate blog statistics", %{
"type" => "object", "type" => "object",
"properties" => %{} "properties" => %{}
}) })
@@ -329,7 +336,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "get_blog_stats", name: "get_blog_stats",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_blog_stats", "get_blog_stats",
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.", "Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
%{"type" => "object", "properties" => %{}} %{"type" => "object", "properties" => %{}}
@@ -338,7 +345,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "check_term", name: "check_term",
spec: spec:
tool_spec( Schemas.tool_spec(
"check_term", "check_term",
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.", "Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.",
%{ %{
@@ -351,16 +358,16 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "search_posts", name: "search_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"search_posts", "search_posts",
"Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.", "Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.",
post_search_schema(true) Schemas.post_search_schema(true)
) )
}, },
%{ %{
name: "read_post", name: "read_post",
spec: spec:
tool_spec( Schemas.tool_spec(
"read_post", "read_post",
"Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.", "Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
%{ %{
@@ -373,7 +380,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "read_post_by_slug", name: "read_post_by_slug",
spec: spec:
tool_spec( Schemas.tool_spec(
"read_post_by_slug", "read_post_by_slug",
"Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.", "Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
%{ %{
@@ -386,37 +393,37 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "list_posts", name: "list_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_posts", "list_posts",
"List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.", "List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.",
post_search_schema(false) Schemas.post_search_schema(false)
) )
}, },
%{ %{
name: "get_media", name: "get_media",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_media", "get_media",
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.", "Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.",
media_id_schema() Schemas.media_id_schema()
) )
}, },
%{ %{
name: "list_media", name: "list_media",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_media", "list_media",
"List concrete media data in the active project, including titles, filenames, MIME types, and update times.", "List concrete media data in the active project, including titles, filenames, MIME types, and update times.",
limit_schema() Schemas.limit_schema()
) )
}, },
%{ %{
name: "view_image", name: "view_image",
spec: spec:
tool_spec( Schemas.tool_spec(
"view_image", "view_image",
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.", "View an image thumbnail as a local data URL for visual inspection. Only works with image media files.",
media_id_schema(%{ Schemas.media_id_schema(%{
"size" => %{"type" => "string", "enum" => ["small", "medium", "large"]} "size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
}) })
) )
@@ -424,25 +431,25 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "update_post_metadata", name: "update_post_metadata",
spec: spec:
tool_spec( Schemas.tool_spec(
"update_post_metadata", "update_post_metadata",
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.", "Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.",
update_post_metadata_schema() Schemas.update_post_metadata_schema()
) )
}, },
%{ %{
name: "update_media_metadata", name: "update_media_metadata",
spec: spec:
tool_spec( Schemas.tool_spec(
"update_media_metadata", "update_media_metadata",
"Update metadata for a media file: title, alt text, caption, or tags.", "Update metadata for a media file: title, alt text, caption, or tags.",
update_media_metadata_schema() Schemas.update_media_metadata_schema()
) )
}, },
%{ %{
name: "list_tags", name: "list_tags",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_tags", "list_tags",
"List all tags used across blog posts with post counts.", "List all tags used across blog posts with post counts.",
%{ %{
@@ -454,7 +461,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "list_categories", name: "list_categories",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_categories", "list_categories",
"List all categories used across blog posts with post counts.", "List all categories used across blog posts with post counts.",
%{"type" => "object", "properties" => %{}} %{"type" => "object", "properties" => %{}}
@@ -463,46 +470,46 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "count_posts", name: "count_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"count_posts", "count_posts",
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.", "Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
count_posts_schema() Schemas.count_posts_schema()
) )
}, },
%{ %{
name: "get_post_backlinks", name: "get_post_backlinks",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_post_backlinks", "get_post_backlinks",
"Get all posts that link to a specific post.", "Get all posts that link to a specific post.",
post_id_schema() Schemas.post_id_schema()
) )
}, },
%{ %{
name: "get_post_outlinks", name: "get_post_outlinks",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_post_outlinks", "get_post_outlinks",
"Get all posts that a specific post links to.", "Get all posts that a specific post links to.",
post_id_schema() Schemas.post_id_schema()
) )
}, },
%{ %{
name: "get_post_media", name: "get_post_media",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_post_media", "get_post_media",
"Get media files linked to a specific post.", "Get media files linked to a specific post.",
post_id_schema() Schemas.post_id_schema()
) )
}, },
%{ %{
name: "get_media_posts", name: "get_media_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_media_posts", "get_media_posts",
"Get posts that use a specific media file.", "Get posts that use a specific media file.",
media_id_schema() Schemas.media_id_schema()
) )
} }
] ]
@@ -515,73 +522,73 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "render_card", name: "render_card",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_card", "render_card",
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.", "Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
render_card_schema() Schemas.render_card_schema()
) )
}, },
%{ %{
name: "render_table", name: "render_table",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_table", "render_table",
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.", "Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
render_table_schema() Schemas.render_table_schema()
) )
}, },
%{ %{
name: "render_chart", name: "render_chart",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_chart", "render_chart",
"Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.", "Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
render_chart_schema() Schemas.render_chart_schema()
) )
}, },
%{ %{
name: "render_form", name: "render_form",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_form", "render_form",
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.", "Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
render_form_schema() Schemas.render_form_schema()
) )
}, },
%{ %{
name: "render_metric", name: "render_metric",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_metric", "render_metric",
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.", "Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
render_metric_schema() Schemas.render_metric_schema()
) )
}, },
%{ %{
name: "render_list", name: "render_list",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_list", "render_list",
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.", "Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
render_list_schema() Schemas.render_list_schema()
) )
}, },
%{ %{
name: "render_tabs", name: "render_tabs",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_tabs", "render_tabs",
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.", "Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
render_tabs_schema() Schemas.render_tabs_schema()
) )
}, },
%{ %{
name: "render_mindmap", name: "render_mindmap",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_mindmap", "render_mindmap",
"Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.", "Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
render_mindmap_schema() Schemas.render_mindmap_schema()
) )
} }
] ]
@@ -590,394 +597,6 @@ defmodule BDS.AI.ChatTools do
end end
end end
defp tool_spec(name, description, parameters) do
%{
"type" => "function",
"function" => %{
"name" => name,
"description" => description,
"parameters" => parameters
}
}
end
defp limit_schema do
%{
"type" => "object",
"properties" => %{
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}
}
}
end
defp post_search_schema(require_query) do
schema = %{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string"},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"language" => %{"type" => "string"},
"missingTranslationLanguage" => %{"type" => "string"},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
"offset" => %{"type" => "integer", "minimum" => 0}
}
}
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
end
defp count_posts_schema do
%{
"type" => "object",
"properties" => %{
"groupBy" => %{
"type" => "array",
"items" => %{
"type" => "string",
"enum" => ["year", "month", "tag", "category", "status"]
}
},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["groupBy"]
}
end
defp post_id_schema do
%{
"type" => "object",
"properties" => %{"postId" => %{"type" => "string"}},
"required" => ["postId"]
}
end
defp media_id_schema(extra_properties \\ %{}) do
%{
"type" => "object",
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
"required" => ["mediaId"]
}
end
defp update_post_metadata_schema do
%{
"type" => "object",
"properties" => %{
"postId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"excerpt" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["postId"]
}
end
defp update_media_metadata_schema do
%{
"type" => "object",
"properties" => %{
"mediaId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"alt" => %{"type" => "string"},
"caption" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["mediaId"]
}
end
defp render_table_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional table title"},
"columns" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Column header names"
},
"rows" => %{
"type" => "array",
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows, each row is an array of cell values"
}
}
}
end
defp render_chart_schema do
%{
"type" => "object",
"properties" => %{
"chartType" => %{
"type" => "string",
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
"description" =>
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
},
"title" => %{"type" => "string", "description" => "Optional chart title"},
"series" => %{
"type" => "array",
"description" => "Array of data points.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Data point label"},
"value" => %{"type" => "number", "description" => "Data point value"},
"segments" => %{
"type" => "array",
"description" =>
"Segments within this data point. Required for stacked-bar and heatmap charts.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "number"}
},
"required" => ["label", "value"]
}
}
},
"required" => ["label"]
}
}
},
"required" => ["chartType", "series"]
}
end
defp render_form_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional form title"},
"fields" => %{
"type" => "array",
"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
defp render_card_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Card title"},
"subtitle" => %{"type" => "string", "description" => "Optional subtitle"},
"body" => %{"type" => "string", "description" => "Card body text (supports markdown)"},
"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
defp render_metric_schema do
%{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Metric label"},
"value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
}
}
end
defp render_list_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional list title"},
"items" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "List items"
}
}
}
end
defp render_tabs_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional tabs title"},
"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
defp render_mindmap_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional mind map title"},
"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
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
defp normalize_limit(_value), do: 10
defp normalize_offset(value) when is_integer(value) and value >= 0, do: value
defp normalize_offset(_value), do: 0
defp search_filters(arguments) do
%{}
|> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"]))
|> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"]))
|> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"]))
|> BDS.MapUtils.maybe_put(
:missing_translation_language,
BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"])
)
|> BDS.MapUtils.maybe_put(:year, arguments["year"])
|> BDS.MapUtils.maybe_put(:month, arguments["month"])
|> BDS.MapUtils.maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|> Map.put(:limit, normalize_limit(arguments["limit"]))
end
defp search_all_counted_posts(project_id, arguments) do
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
filters = Map.put(filters, :limit, max(total, 1))
{:ok, result} = Search.search_posts(project_id, "", filters)
result
end
defp search_result(project_id, query, filters, mapper) do
{:ok, result} = Search.search_posts(project_id, query, filters)
%{
posts: Enum.map(result.posts, mapper),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end
defp with_post(arguments, project_id, not_found_result, success_fun) do defp with_post(arguments, project_id, not_found_result, success_fun) do
case Repo.get_by(Post, case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"], id: arguments["postId"] || arguments["post_id"],

View File

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

View File

@@ -5,6 +5,27 @@ defmodule BDS.Application do
@compiled_env Application.compile_env(:bds, :current_env, Mix.env()) @compiled_env Application.compile_env(:bds, :current_env, Mix.env())
# Children that depend on the boot mode (issue #26): desktop mode runs the
# wx window shell, server mode runs headless behind the SSH daemon. Both
# start the loopback HTTP endpoint and the CLI sync watcher.
def mode_children(:server, _env) do
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher, BDS.Server]
end
# CLI mode (issue #25): a one-shot command run against the shared database.
# No HTTP listener, SSH daemon, window, or sync watcher — the CLI is the
# external writer the watcher in a concurrently running app listens for.
def mode_children(:cli, _env), do: []
# Local TUI: the headless server plus a TUI on the launching terminal, so
# a GUI (via tunnel) and the local terminal can work in parallel. Quitting
# this TUI stops the whole VM — the terminal is the app in this mode.
# SSH-served TUI sessions never get this flag and stay session-local.
def mode_children(:tui, env),
do: mode_children(:server, env) ++ [{BDS.TUI, [stop_vm_on_exit: true]}]
def mode_children(:desktop, env), do: desktop_children(env)
def desktop_children(env \\ nil) def desktop_children(env \\ nil)
def desktop_children(:test) do def desktop_children(:test) do
@@ -41,7 +62,7 @@ defmodule BDS.Application do
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor}, {Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
BDS.Scripting.JobSupervisor, BDS.Scripting.JobSupervisor,
BDS.Embeddings.Index BDS.Embeddings.Index
] ++ embedding_children() ++ desktop_children(current_env()) ] ++ embedding_children() ++ mode_children(boot_mode(), 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)
@@ -61,10 +82,26 @@ defmodule BDS.Application do
Application.get_env(:bds, :current_env_override) || @compiled_env Application.get_env(:bds, :current_env_override) || @compiled_env
end end
# Desktop mode without a graphical display boots the TUI instead
# (issue #33); the log line explains the surprise mode switch.
defp boot_mode do
resolved = BDS.Server.mode()
effective = BDS.Server.effective_mode(resolved)
if effective != resolved do
require Logger
Logger.info("No graphical display available, starting the terminal UI instead")
end
effective
end
defp desktop_window_children do defp desktop_window_children do
if desktop_automation?() do if desktop_automation?() do
[] []
else else
disable_auto_window_menu()
window_opts = window_opts =
BDS.Desktop.MainWindow.window_options( BDS.Desktop.MainWindow.window_options(
menubar: BDS.Desktop.MenuBar, menubar: BDS.Desktop.MenuBar,
@@ -75,11 +112,23 @@ 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, []} {BDS.Desktop.DeepLink, []},
{BDS.Desktop.RemoteConnection, []}
] ]
end end
end end
# wxOSX injects an untracked "Window" menu into the menubar at install time,
# which races elixir-desktop's async menu populate and ends up at an
# unstable position. We disable it and ship our own :window menu group
# (BDS.UI.MenuBar) placed between Blog and Help.
defp disable_auto_window_menu do
if :os.type() == {:unix, :darwin} do
:wx.set_env(Desktop.Env.wx_env())
:wxMenuBar.setAutoWindowMenu(false)
end
end
defp desktop_automation? do defp desktop_automation? do
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
end end

View File

@@ -22,6 +22,11 @@ defmodule BDS.Blogmark do
@scheme "bds2" @scheme "bds2"
@new_post_action "new-post" @new_post_action "new-post"
# Input-hardening limits mirrored from the legacy bDS app (shared/blogmark.ts).
@max_title_length 200
@max_url_length 2048
@control_chars ~r/[\x00-\x1F\x7F]/
@type candidate :: %{required(String.t()) => term()} @type candidate :: %{required(String.t()) => term()}
@type receive_result :: %{ @type receive_result :: %{
@@ -34,7 +39,8 @@ defmodule BDS.Blogmark do
Parses a `bds2://new-post` deep link into a post candidate map. Parses a `bds2://new-post` deep link into a post candidate map.
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`, Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
`tags` and `categories`, or an error when the scheme or action is unsupported. `tags`, `categories` and optional `project_id`, or an error when the scheme
or action is unsupported.
""" """
@spec parse_deep_link(String.t()) :: @spec parse_deep_link(String.t()) ::
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url} {:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
@@ -54,10 +60,27 @@ defmodule BDS.Blogmark do
end end
end end
@doc """
Returns the `project_id` a deep link targets, or `nil` when the link carries
none or cannot be parsed. Used by the shell to switch to the link's project
before importing.
"""
@spec deep_link_project_id(String.t()) :: String.t() | nil
def deep_link_project_id(url) when is_binary(url) do
case parse_deep_link(url) do
{:ok, %{"project_id" => project_id}} -> project_id
_other -> nil
end
end
@doc """ @doc """
Receives a blogmark deep link for `project_id`: parses it, runs the transform Receives a blogmark deep link for `project_id`: parses it, runs the transform
pipeline, and creates a draft post from the resulting candidate. pipeline, and creates a draft post from the resulting candidate.
When the deep link URL carries a `project_id` query parameter, it takes
precedence over the passed `project_id` so the bookmarklet can target a
specific project regardless of which project is currently active.
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
budget-enforced transform messages and `errors` records any failed transforms. budget-enforced transform messages and `errors` records any failed transforms.
""" """
@@ -66,10 +89,12 @@ defmodule BDS.Blogmark do
def receive_deep_link(project_id, url, opts \\ []) def receive_deep_link(project_id, url, opts \\ [])
when is_binary(project_id) and is_binary(url) and is_list(opts) do when is_binary(project_id) and is_binary(url) and is_list(opts) do
with {:ok, candidate} <- parse_deep_link(url), with {:ok, candidate} <- parse_deep_link(url),
target_project <- resolve_target_project(project_id, candidate),
candidate <- apply_default_content(candidate),
{:ok, %{data: data, toasts: toasts, errors: errors}} <- {:ok, %{data: data, toasts: toasts, errors: errors}} <-
Transforms.run(project_id, candidate, opts), Transforms.run(target_project, candidate, opts),
data <- apply_default_category(project_id, data), data <- apply_default_category(target_project, data),
{:ok, post} <- create_draft(project_id, data) do {:ok, post} <- create_draft(target_project, data) do
{:ok, %{post: post, toasts: toasts, errors: errors}} {:ok, %{post: post, toasts: toasts, errors: errors}}
end end
end end
@@ -77,15 +102,102 @@ defmodule BDS.Blogmark do
defp candidate_from_query(query) do defp candidate_from_query(query) do
params = URI.decode_query(query || "") params = URI.decode_query(query || "")
project_id =
case Map.get(params, "project_id") do
nil -> nil
"" -> nil
id -> id
end
url = sanitize_http_url(Map.get(params, "url"))
%{ %{
"title" => Map.get(params, "title", "") |> to_string(), "title" => sanitize_title(Map.get(params, "title"), url_host(url)),
"url" => optional(params, "url"), "url" => url,
"content" => optional(params, "content"), "content" => optional(params, "content"),
"tags" => list_param(params, "tags"), "tags" => list_param(params, "tags"),
"categories" => list_param(params, "categories") "categories" => list_param(params, "categories"),
"project_id" => project_id
} }
end end
# Strip control characters, trim, cap length; fall back to the URL host when
# blank — mirrors bDS sanitizeTitle. Title/url are single-line fields, so
# control-character stripping is safe here (unlike the multi-line body).
defp sanitize_title(raw, fallback_host) do
title =
raw
|> to_string()
|> strip_control()
|> String.trim()
|> String.slice(0, @max_title_length)
if title == "", do: fallback_host || "", else: title
end
# Accept only http(s) URLs, strip credentials and fragment, enforce a length
# cap; anything else collapses to nil so it can never reach the post body.
defp sanitize_http_url(raw) when is_binary(raw) do
trimmed = raw |> strip_control() |> String.trim()
case URI.parse(trimmed) do
%URI{scheme: scheme, host: host} = uri
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
normalized = URI.to_string(%URI{uri | userinfo: nil, fragment: nil})
if String.length(normalized) <= @max_url_length, do: normalized, else: nil
_ ->
nil
end
end
defp sanitize_http_url(_other), do: nil
defp url_host(url) when is_binary(url), do: URI.parse(url).host
defp url_host(_other), do: nil
defp strip_control(value), do: String.replace(value, @control_chars, "")
# If the deep link carries a project_id, route to that project; otherwise
# use the currently active project passed by the caller. This lets the
# bookmarklet target a specific project even when a different one is open.
defp resolve_target_project(_caller_project_id, %{"project_id" => pid}) when is_binary(pid) do
pid
end
defp resolve_target_project(project_id, _candidate), do: project_id
# Match the legacy bDS app: when the bookmarklet supplies no body, seed the
# post content with a markdown link to the bookmarked page so transforms run
# against the same input. Explicit content always wins.
defp apply_default_content(candidate) do
with nil <- optional_string(Map.get(candidate, "content")),
url when is_binary(url) <- optional_string(Map.get(candidate, "url")) do
title = Map.get(candidate, "title", "")
Map.put(candidate, "content", blogmark_markdown_link(title, url))
else
_ -> candidate
end
end
defp blogmark_markdown_link(title, url) do
"[" <> escape_markdown_link_text(title) <> "](" <> url <> ")"
end
# Mirrors bDS escapeMarkdownLinkText: backslash first, then brackets/parens,
# then collapse newlines to spaces.
defp escape_markdown_link_text(value) do
value
|> to_string()
|> String.trim()
|> String.replace("\\", "\\\\")
|> String.replace("[", "\\[")
|> String.replace("]", "\\]")
|> String.replace("(", "\\(")
|> String.replace(")", "\\)")
|> String.replace(~r/\r?\n/, " ")
end
defp optional(params, key) do defp optional(params, key) do
case Map.get(params, key) do case Map.get(params, key) do
nil -> nil nil -> nil

View File

@@ -54,13 +54,14 @@ defmodule BDS.BoundedAtoms do
:fill_missing_translations, :fill_missing_translations,
:find_duplicates, :find_duplicates,
:generate_sitemap, :generate_sitemap,
:force_render_site,
:validate_site, :validate_site,
:upload_site, :upload_site,
:documentation, :documentation,
:api_documentation, :api_documentation,
:close_tab :close_tab
] ]
@menu_actions MenuBar.default_groups(dev_mode?: true) @menu_actions MenuBar.default_groups(dev_mode?: true, window_menu?: true)
|> Enum.flat_map(fn group -> |> Enum.flat_map(fn group ->
Enum.flat_map(group.items, fn Enum.flat_map(group.items, fn
%{separator: true} -> [] %{separator: true} -> []

335
lib/bds/cli.ex Normal file
View File

@@ -0,0 +1,335 @@
defmodule BDS.CLI do
@moduledoc """
Command-line interface for managing content in a bDS2 workspace (issue #25).
Runs inside the release VM (`BDS_MODE=cli`) against the same settings and
cache database as the GUI/TUI application; mutations write
`BDS.CliSync.Notification` rows so a concurrently running app picks them
up through `BDS.CliSync.Watcher`.
The launcher script (`cli/bin/bds-cli` in the release, installable to
`~/.local/bin` via `BDS.CLI.Install`) passes the argv via `BDS_CLI_ARGV`
(unit-separator joined) because `bin/bds eval` cannot forward arguments.
"""
alias BDS.CLI.Commands
@argv_env "BDS_CLI_ARGV"
@argv_separator <<0x1F>>
@doc "Release entry point: run the command from `BDS_CLI_ARGV` and halt."
@spec main() :: no_return()
def main do
System.halt(run(env_argv()))
end
@doc "Decodes the unit-separator-joined argv from the launcher script."
@spec env_argv(String.t() | nil) :: [String.t()]
def env_argv(value \\ System.get_env(@argv_env))
def env_argv(nil), do: []
def env_argv(value), do: String.split(value, @argv_separator, trim: true)
@doc "Parses and executes an argv, returning the process exit code."
@spec run([String.t()]) :: non_neg_integer()
def run(argv) when is_list(argv) do
optimus = optimus()
case Optimus.parse(optimus, argv) do
{:ok, %Optimus.ParseResult{}} ->
print_help(optimus, [])
0
{:ok, subcommand_path, parse_result} ->
execute(subcommand_path, parse_result)
:help ->
print_help(optimus, [])
0
{:help, subcommand_path} ->
print_help(optimus, subcommand_path)
0
:version ->
IO.puts(version())
0
{:error, errors} ->
print_errors(optimus, [], errors)
1
{:error, subcommand_path, errors} ->
print_errors(optimus, subcommand_path, errors)
1
end
end
@doc "Executes a parsed subcommand, returning the process exit code."
@spec execute([atom()], Optimus.ParseResult.t()) :: non_neg_integer()
def execute(subcommand_path, parse_result) do
{:ok, _apps} = Application.ensure_all_started(:bds)
subcommand_path
|> Commands.dispatch(parse_result)
|> report()
end
defp report(:ok), do: 0
defp report({:ok, message}) when is_binary(message) do
IO.puts(message)
0
end
defp report({:error, message}) do
IO.puts(:stderr, "Error: " <> format_error(message))
1
end
defp format_error(message) when is_binary(message), do: message
defp format_error(%{message: message}) when is_binary(message), do: message
defp format_error(other), do: inspect(other)
@repair_parts ~w(post-links media-links thumbnails embeddings search)
@doc "The Optimus command definition (public for tests and help rendering)."
def optimus do
Optimus.new!(
name: "bds-cli",
description: "bDS2 workspace CLI",
about:
"Manages content in a bDS2 workspace using the same settings and " <>
"cache database as the desktop application.",
version: version(),
allow_unknown_args: false,
parse_double_dash: true,
subcommands: [
rebuild: [
name: "rebuild",
about: "Rebuild the caching database from the workspace files",
flags: [
incremental: [
long: "--incremental",
help:
"Run a metadata diff and auto-apply every difference from " <>
"file to database instead of a full rebuild"
]
]
],
repair: [
name: "repair",
about: "Run one of the standard repair tasks outside the full rebuild",
args: [
part: [
value_name: "PART",
help: "One of: " <> Enum.join(@repair_parts, ", "),
required: true,
parser: &parse_repair_part/1
]
]
],
render: [
name: "render",
about: "Render the blog from the current content",
flags: [
incremental: [
long: "--incremental",
help: "Validate the generated output and apply only the differences"
],
force: [
long: "--force",
help: "Full re-render ignoring (but updating) stored content hashes"
]
]
],
upload: [
name: "upload",
about: "Upload the rendered site to the configured host (rsync/scp)"
],
push: [
name: "push",
about: "Push the project repository to its origin"
],
pull: [
name: "pull",
about: "Pull the project repository and update the cache database"
],
post: [
name: "post",
about:
"Create a post from parameters or from JSON on stdin " <>
"(keys: title, content, excerpt, author, language, tags, categories, template)",
flags: [
stdin: [long: "--stdin", help: "Read post data as JSON from stdin"],
no_translate: [
long: "--no-translate",
help: "Skip automatic translation of the created post"
]
],
options: post_content_options()
],
media: [
name: "media",
about:
"Import an image with automatically generated title, alt text, " <>
"caption and translations",
args: [
file: [
value_name: "FILE",
help: "Path of the image file to import",
required: true
]
],
options: [
language: [
long: "--language",
value_name: "LANG",
help: "Language for the generated texts (defaults to the project main language)"
]
]
],
gallery: [
name: "gallery",
about:
"Create a gallery post and import all referenced images as new " <>
"media with generated texts and translations",
allow_unknown_args: true,
flags: [
stdin: [
long: "--stdin",
help: "Read gallery data as JSON from stdin (adds an \"images\" key to post keys)"
]
],
options: post_content_options()
],
config: [
name: "config",
about: "Read and write global preference values",
subcommands: [
get: [
name: "get",
about: "Print a preference value",
args: [key: [value_name: "KEY", help: "Preference key", required: true]]
],
set: [
name: "set",
about: "Set a preference value",
args: [
key: [value_name: "KEY", help: "Preference key", required: true],
value: [value_name: "VALUE", help: "New value", required: true]
]
],
list: [
name: "list",
about: "List all preference keys and values"
]
]
],
project: [
name: "project",
about: "Manage the projects registered in the cache database",
subcommands: [
list: [
name: "list",
about: "List all projects"
],
add: [
name: "add",
about: "Open a project folder and add it to the cache database",
args: [
path: [value_name: "PATH", help: "Project data folder", required: true]
],
options: [
name: [
long: "--name",
value_name: "NAME",
help: "Project name (defaults to the folder name)"
]
]
],
switch: [
name: "switch",
about: "Switch the active project",
args: [
project: [
value_name: "PROJECT",
help: "Project id, slug, or name",
required: true
]
]
]
]
],
tui: [
name: "tui",
about: "Open the app in TUI mode for interactive use"
],
lua: [
name: "lua",
about: "Run a utility (long-running task) Lua script from the database",
allow_unknown_args: true,
args: [
script: [
value_name: "SCRIPT",
help: "Script slug in the active project",
required: true
]
]
]
]
)
end
defp post_content_options do
[
title: [long: "--title", value_name: "TITLE", help: "Post title"],
content: [long: "--content", value_name: "MARKDOWN", help: "Post body (markdown)"],
excerpt: [long: "--excerpt", value_name: "TEXT", help: "Post excerpt"],
author: [long: "--author", value_name: "NAME", help: "Post author"],
language: [
long: "--language",
value_name: "LANG",
help: "Post language (auto-detected from the content when omitted)"
],
template: [long: "--template", value_name: "SLUG", help: "Template slug"],
tags: [long: "--tags", value_name: "TAGS", help: "Comma-separated tags"],
categories: [
long: "--categories",
value_name: "CATEGORIES",
help: "Comma-separated categories"
]
]
end
defp parse_repair_part(value) when value in @repair_parts, do: {:ok, value}
defp parse_repair_part(value),
do: {:error, "unknown repair part #{inspect(value)}; expected one of: #{Enum.join(@repair_parts, ", ")}"}
defp print_help(optimus, subcommand_path) do
optimus
|> Optimus.Help.help(subcommand_path, terminal_width())
|> Enum.each(&IO.puts/1)
end
defp print_errors(optimus, [], errors) do
optimus |> Optimus.Errors.format(errors) |> Enum.each(&IO.puts(:stderr, &1))
end
defp print_errors(optimus, subcommand_path, errors) do
optimus
|> Optimus.Errors.format(subcommand_path, errors)
|> Enum.each(&IO.puts(:stderr, &1))
end
defp terminal_width do
case Optimus.Term.width() do
{:ok, width} -> width
_other -> 80
end
end
defp version do
to_string(Application.spec(:bds, :vsn) || "dev")
end
end

696
lib/bds/cli/commands.ex Normal file
View File

@@ -0,0 +1,696 @@
defmodule BDS.CLI.Commands do
@moduledoc """
Implementations of the `bds-cli` subcommands (issue #25).
Every command runs synchronously against the engine modules the GUI uses
and returns `:ok`, `{:ok, message}`, or `{:error, message}`. Mutations
write `BDS.CliSync` notification rows so a concurrently running app
refreshes its state (see `specs/cli_sync.allium`).
"""
import Ecto.Query
alias BDS.AI
alias BDS.CliSync
alias BDS.Desktop.ShellLive.GalleryImport
alias BDS.Generation
alias BDS.Git
alias BDS.Maintenance
alias BDS.Media
alias BDS.Metadata
alias BDS.Posts
alias BDS.Posts.AutoTranslation
alias BDS.Projects
alias BDS.Publishing
alias BDS.Repo
alias BDS.Scripting
alias BDS.Scripts
alias BDS.Scripts.Script
alias BDS.Search
alias BDS.Settings
alias BDS.Tasks
@site_sections [:core, :single, :category, :tag, :date]
@bulk_entity_types ~w(post media script template)
@gallery_concurrency 2
@poll_interval_ms 250
@doc "Dispatches a parsed subcommand path to its implementation."
@spec dispatch([atom()], Optimus.ParseResult.t()) :: :ok | {:ok, String.t()} | {:error, term()}
def dispatch([:rebuild], %{flags: %{incremental: true}}),
do: with_project(&incremental_rebuild/1)
def dispatch([:rebuild], _parse_result), do: with_project(&full_rebuild/1)
def dispatch([:repair], %{args: %{part: part}}),
do: with_project(&repair(part, &1))
def dispatch([:render], %{flags: %{incremental: true, force: true}}),
do: {:error, "--incremental and --force are mutually exclusive"}
def dispatch([:render], %{flags: %{incremental: true}}),
do: with_project(&incremental_render/1)
def dispatch([:render], %{flags: flags}),
do: with_project(&full_render(&1, flags[:force] == true))
def dispatch([:upload], _parse_result), do: with_project(&upload/1)
def dispatch([:push], _parse_result) do
with_project(fn project ->
with {:ok, result} <- Git.push(project.id) do
print_git_output(result)
{:ok, "Pushed"}
end
end)
end
def dispatch([:pull], _parse_result) do
with_project(fn project ->
with {:ok, result} <- Git.pull(project.id) do
print_git_output(result)
# Update the cache database from the pulled files, like the app's
# incremental rebuild: file→db for every difference plus orphans.
incremental_rebuild(project)
end
end)
end
def dispatch([:post], %{flags: flags, options: options}) do
with_project(fn project ->
with {:ok, attrs} <- post_attrs(flags, options),
{:ok, attrs} <- ensure_language(attrs),
{:ok, post} <- create_post(project, attrs) do
unless flags[:no_translate], do: translate_post(post)
{:ok, "Created post #{post.id} (#{post.slug}, #{post.language})"}
end
end)
end
def dispatch([:media], %{args: %{file: file}, options: options}) do
with_project(fn project ->
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
:ok <- ensure_files_exist([file]) do
language = present(options[:language]) || metadata.main_language
targets = GalleryImport.translate_targets(metadata, language)
case GalleryImport.import_and_enrich(file, project.id, language, targets) do
{:ok, media, title} ->
:ok = notify("media", media.id, :created)
{:ok, "Imported media #{media.id} (#{title})"}
{:error, reason} ->
{:error, format_reason(reason)}
end
end
end)
end
def dispatch([:gallery], %{flags: flags, options: options, unknown: unknown}) do
with_project(fn project ->
with {:ok, attrs, images} <- gallery_attrs(flags, options, unknown),
:ok <- ensure_files_exist(images),
{:ok, attrs} <- ensure_language(attrs),
{:ok, post} <- create_post(project, attrs) do
import_gallery_images(project, post, images)
:ok = notify("media", "*", :created)
:ok = notify("post", post.id, :updated)
unless flags[:no_translate], do: translate_post(post)
{:ok, "Created gallery post #{post.id} (#{post.slug}) with #{length(images)} images"}
end
end)
end
def dispatch([:config, :get], %{args: %{key: key}}) do
case Settings.get_global_setting(key) do
nil -> {:error, "#{key} is not set"}
value -> {:ok, value}
end
end
def dispatch([:config, :set], %{args: %{key: key, value: value}}) do
with :ok <- Settings.put_global_setting(key, value) do
:ok = notify("setting", key, :updated)
{:ok, "#{key} = #{value}"}
end
end
def dispatch([:config, :list], _parse_result) do
Settings.list_global_settings()
|> Enum.each(fn {key, value} -> IO.puts("#{key}=#{value}") end)
:ok
end
def dispatch([:project, :list], _parse_result) do
Enum.each(Projects.list_projects(), fn project ->
marker = if project.is_active, do: "* ", else: " "
IO.puts("#{marker}#{project.id} #{project.slug} #{project.name}")
end)
:ok
end
def dispatch([:project, :add], %{args: %{path: path}, options: options}) do
expanded = Path.expand(path)
if File.dir?(expanded) do
name = present(options[:name]) || Path.basename(expanded)
with {:ok, project} <- Projects.create_project(%{name: name, data_path: expanded}) do
:ok = notify("project", project.id, :created)
{:ok, "Added project #{project.id} (#{project.slug}); switch with: project switch #{project.slug}"}
end
else
{:error, "#{expanded} is not a directory"}
end
end
def dispatch([:project, :switch], %{args: %{project: reference}}) do
with {:ok, project} <- resolve_project(reference),
{:ok, project} <- Projects.set_active_project(project.id) do
:ok = notify("project", project.id, :updated)
{:ok, "Active project: #{project.name}"}
else
{:error, :not_found} -> {:error, "No project matches #{inspect(reference)}"}
{:error, reason} -> {:error, format_reason(reason)}
end
end
def dispatch([:tui], _parse_result) do
{:error,
"The TUI is started by the bds-cli launcher script (BDS_MODE=tui); " <>
"it is not available through a direct BDS.CLI invocation"}
end
def dispatch([:lua], %{args: %{script: slug}, unknown: script_args}) do
with_project(fn project ->
case Repo.one(
from script in Script,
where: script.project_id == ^project.id and script.slug == ^slug
) do
nil ->
{:error, "No script with slug #{inspect(slug)} in the active project"}
%Script{kind: kind} when kind != :utility ->
{:error, "Script #{inspect(slug)} is a #{kind} script; only utility (task) scripts can be run"}
%Script{enabled: false} ->
{:error, "Script #{inspect(slug)} is disabled"}
script ->
run_lua_job(project, script, script_args)
end
end)
end
# ── rebuild ───────────────────────────────────────────────────────────────
defp full_rebuild(project) do
project.id
|> Maintenance.full_rebuild_steps()
|> Enum.reduce_while(:ok, fn step, :ok ->
IO.puts("==> #{step.name}")
case step.work.(progress_printer()) do
{:error, message} -> {:halt, {:error, message}}
_result -> {:cont, :ok}
end
end)
|> case do
:ok ->
notify_bulk_change()
{:ok, "Rebuild complete"}
error ->
error
end
end
defp incremental_rebuild(project) do
reporter = progress_printer()
{:ok, diff} = Maintenance.metadata_diff(project.id, on_progress: reporter)
items =
Enum.map(diff.diff_reports, &%{entity_type: &1.entity_type, entity_id: &1.entity_id})
orphans = Enum.map(diff.orphan_reports, &%{file_path: &1.file_path})
if items != [] do
{:ok, _repair} =
Maintenance.repair_metadata_diff(project.id, :file_to_db, items, on_progress: reporter)
end
if orphans != [] do
{:ok, _import} =
Maintenance.import_metadata_diff_orphans(project.id, orphans, on_progress: reporter)
end
if items != [] or orphans != [], do: notify_bulk_change()
{:ok,
"Applied #{length(items)} differences and imported #{length(orphans)} orphan files from the filesystem"}
end
# ── repair ────────────────────────────────────────────────────────────────
defp repair("post-links", project) do
:ok = Posts.rebuild_post_links(project.id, on_progress: progress_printer())
:ok = notify("post", "*", :updated)
{:ok, "Post links rebuilt"}
end
defp repair("media-links", project) do
{:ok, result} = Media.rebuild_media_links(project.id, on_progress: progress_printer())
:ok = notify("media", "*", :updated)
{:ok, "Media links rebuilt (#{result.links} links)"}
end
defp repair("thumbnails", project) do
result = Media.regenerate_missing_thumbnails(project.id, on_progress: progress_printer())
:ok = notify("media", "*", :updated)
{:ok, "Missing thumbnails regenerated (#{inspect(Map.get(result, :generated, 0))} generated)"}
end
defp repair("embeddings", project) do
case Maintenance.rebuild_embedding_index(project.id, on_progress: progress_printer()) do
{:error, message} -> {:error, message}
result -> {:ok, "Embedding index rebuilt (#{result.rebuilt_count} posts)"}
end
end
defp repair("search", project) do
:ok = Search.reindex_posts(project.id, on_progress: progress_printer())
:ok = Search.reindex_media(project.id, on_progress: progress_printer())
{:ok, "Search text reindexed"}
end
# ── render ────────────────────────────────────────────────────────────────
defp full_render(project, force?) do
render_opts = if force?, do: [force: true], else: []
Enum.each(@site_sections, fn section ->
IO.puts("==> Rendering #{section}")
{:ok, _result} =
Generation.render_site_section(
project.id,
section,
[on_progress: progress_printer()] ++ render_opts
)
end)
IO.puts("==> Building search index")
{:ok, _index} =
Generation.build_site_search_index(
project.id,
[on_progress: progress_printer()] ++ render_opts
)
{:ok, "Site rendered"}
end
defp incremental_render(project) do
{:ok, report} =
Generation.validate_site(project.id, @site_sections, on_progress: progress_printer())
{:ok, applied} =
Generation.apply_validation(project.id, report, on_progress: progress_printer())
{:ok,
"Validation applied (#{applied.rendered_url_count} rendered, " <>
"#{applied.deleted_url_count} deleted)"}
end
# ── upload ────────────────────────────────────────────────────────────────
defp upload(project) do
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
{:ok, credentials} <- upload_credentials(metadata.publishing_preferences),
{:ok, job} <- Publishing.upload_site(project.id, credentials) do
await_task(job.task_id, "Upload complete")
end
end
defp upload_credentials(prefs) when is_map(prefs) do
credentials = %{
ssh_host: Map.get(prefs, "ssh_host"),
ssh_user: Map.get(prefs, "ssh_user"),
ssh_remote_path: Map.get(prefs, "ssh_remote_path"),
ssh_mode: Map.get(prefs, "ssh_mode")
}
if Enum.all?(
[credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path],
&is_binary/1
) do
{:ok, credentials}
else
{:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
end
end
defp upload_credentials(_prefs),
do: {:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
# ── post / gallery ────────────────────────────────────────────────────────
defp post_attrs(%{stdin: true}, _options) do
with {:ok, data} <- read_stdin_json() do
json_post_attrs(data)
end
end
defp post_attrs(_flags, options) do
case present(options[:title]) do
nil ->
{:error, "--title is required (or pass --stdin with JSON post data)"}
title ->
{:ok,
%{
title: title,
content: options[:content] || "",
excerpt: present(options[:excerpt]),
author: present(options[:author]),
language: present(options[:language]),
template_slug: present(options[:template]),
tags: split_list(options[:tags]),
categories: split_list(options[:categories])
}}
end
end
defp gallery_attrs(%{stdin: true}, _options, _unknown) do
with {:ok, data} <- read_stdin_json(),
{:ok, attrs} <- json_post_attrs(data) do
case Map.get(data, "images") do
images when is_list(images) and images != [] -> {:ok, attrs, images}
_other -> {:error, "JSON gallery data needs a non-empty \"images\" array"}
end
end
end
defp gallery_attrs(flags, options, unknown) do
case unknown do
[] ->
{:error, "Pass the image files as arguments (or use --stdin with JSON gallery data)"}
images ->
with {:ok, attrs} <- post_attrs(flags, options) do
{:ok, attrs, images}
end
end
end
defp json_post_attrs(data) when is_map(data) do
case present(Map.get(data, "title")) do
nil ->
{:error, "JSON post data needs a \"title\""}
title ->
{:ok,
%{
title: title,
content: Map.get(data, "content") || "",
excerpt: present(Map.get(data, "excerpt")),
author: present(Map.get(data, "author")),
language: present(Map.get(data, "language")),
template_slug: present(Map.get(data, "template")),
tags: json_list(Map.get(data, "tags")),
categories: json_list(Map.get(data, "categories"))
}}
end
end
defp json_post_attrs(_data), do: {:error, "JSON post data must be an object"}
defp read_stdin_json do
case IO.read(:stdio, :eof) do
:eof ->
{:error, "No JSON data on stdin"}
{:error, reason} ->
{:error, "Could not read stdin: #{inspect(reason)}"}
data ->
case Jason.decode(data) do
{:ok, decoded} -> {:ok, decoded}
{:error, error} -> {:error, "Invalid JSON on stdin: #{Exception.message(error)}"}
end
end
end
# Language auto-detection (issue #25): ask the configured AI endpoint —
# BDS.AI internally routes to the local model in airplane mode — and fall
# back to the offline search heuristic, informing the user (the CLI
# equivalent of the airplane-mode toast).
defp ensure_language(%{language: language} = attrs) when is_binary(language), do: {:ok, attrs}
defp ensure_language(attrs) do
text = String.trim("#{attrs.title}\n#{attrs.content}")
language =
case AI.detect_language(text) do
{:ok, %{language_code: language}} when is_binary(language) ->
language
_other ->
IO.puts("AI language detection unavailable; using the offline heuristic")
Search.detect_language(text)
end
{:ok, %{attrs | language: language}}
end
defp create_post(project, attrs) do
case Posts.create_post(Map.put(attrs, :project_id, project.id)) do
{:ok, post} ->
:ok = notify("post", post.id, :created)
{:ok, post}
{:error, %Ecto.Changeset{} = changeset} ->
{:error, changeset_message(changeset)}
end
end
# Automatic translation mirrors the GUI: schedule the background tasks and
# wait for them, so the CLI exits only when the work is done. When nothing
# gets scheduled (airplane mode without a local model, or a single-language
# blog) the user is told instead of a silent no-op.
defp translate_post(post) do
before_count = length(active_tasks())
:ok = AutoTranslation.maybe_schedule(post)
case length(active_tasks()) - before_count do
queued when queued > 0 ->
IO.puts("Waiting for #{queued} translation task(s)...")
await_active_tasks()
:ok = notify("post", post.id, :updated)
_none ->
IO.puts("Automatic translation was not scheduled (offline, unconfigured AI, or nothing to translate)")
end
:ok
end
defp import_gallery_images(project, post, images) do
{:ok, metadata} = Metadata.get_project_metadata(project.id)
language = post.language || metadata.main_language
parent = self()
runner =
Task.async(fn ->
GalleryImport.start(
images,
project.id,
post.id,
language,
@gallery_concurrency,
parent
)
end)
print_gallery_progress()
Task.await(runner, :infinity)
end
defp print_gallery_progress do
receive do
{:add_image_processed, title} ->
IO.puts("Imported #{title}")
print_gallery_progress()
{:add_image_error, path, reason} ->
IO.puts(:stderr, "Failed to import #{path}: #{format_reason(reason)}")
print_gallery_progress()
{:add_images_complete, count} ->
IO.puts("Processed #{count} image(s)")
:ok
end
end
defp ensure_files_exist(paths) do
case Enum.reject(paths, &File.regular?/1) do
[] -> :ok
missing -> {:error, "No such file: #{Enum.join(missing, ", ")}"}
end
end
# ── lua ───────────────────────────────────────────────────────────────────
# Utility ("task") scripts run with the managed-job execution budget
# (unlimited time/reductions) but synchronously — the CLI is the one
# waiting for the long-running activity.
defp run_lua_job(project, script, args) do
scripting_config = Application.fetch_env!(:bds, :scripting)
case Scripting.execute_project_script(
project.id,
Scripts.resolved_content(script),
script.entrypoint || "main",
args,
timeout: Keyword.get(scripting_config, :job_timeout, :infinity),
max_reductions: Keyword.get(scripting_config, :job_max_reductions, :none),
on_output: fn text -> IO.puts(text) end
) do
{:ok, nil} -> {:ok, "Script finished"}
{:ok, result} -> {:ok, "Script finished: #{format_reason(result)}"}
{:error, reason} -> {:error, "Script failed: #{format_reason(reason)}"}
end
end
# ── shared helpers ────────────────────────────────────────────────────────
defp with_project(fun) do
case Projects.get_active_project() do
nil -> {:error, "No active project selected; use: project switch <project>"}
project -> fun.(project)
end
end
defp resolve_project(reference) do
projects = Projects.list_projects()
found =
Enum.find(projects, fn project ->
reference in [project.id, project.slug, project.name]
end)
if found, do: {:ok, found}, else: {:error, :not_found}
end
defp await_task(task_id, success_message) do
case Tasks.get_task(task_id) do
nil ->
{:error, "Task disappeared before completion"}
%{status: :completed} ->
{:ok, success_message}
%{status: :failed} = task ->
{:error, format_reason(task.error || "task failed")}
%{message: message} = task ->
print_progress(task.progress || 0.0, message)
Process.sleep(@poll_interval_ms)
await_task(task_id, success_message)
end
end
defp active_tasks do
Enum.filter(Tasks.list_tasks(), &(&1.status in [:pending, :running]))
end
defp await_active_tasks do
case active_tasks() do
[] ->
:ok
_tasks ->
Process.sleep(@poll_interval_ms)
await_active_tasks()
end
end
@doc """
A 2-arity progress callback that prints deduplicated `[ 42%] message`
lines to stdout.
"""
def progress_printer do
&print_progress/2
end
defp print_progress(value, message) do
percent = value |> Kernel.*(100) |> round() |> min(100) |> max(0)
line = {percent, message}
if Process.get({__MODULE__, :last_progress}) != line do
Process.put({__MODULE__, :last_progress}, line)
if is_binary(message) and message != "" do
IO.puts("[#{String.pad_leading(Integer.to_string(percent), 3)}%] #{message}")
end
end
:ok
end
defp print_git_output(%{output: output}) when is_binary(output) and output != "" do
IO.puts(String.trim_trailing(output))
end
defp print_git_output(_result), do: :ok
defp notify(entity_type, entity_id, action) do
{:ok, _notification} = CliSync.cli_mutation_performed(entity_type, entity_id, action)
:ok
end
# Bulk maintenance touched an unknown set of rows; a wildcard notification
# per entity type makes a running app reload its lists.
defp notify_bulk_change do
Enum.each(@bulk_entity_types, &notify(&1, "*", :updated))
end
defp split_list(nil), do: []
defp split_list(value) when is_binary(value) do
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp json_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
defp json_list(value) when is_binary(value), do: split_list(value)
defp json_list(_value), do: []
defp present(nil), do: nil
defp present(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
trimmed -> trimmed
end
end
defp changeset_message(%Ecto.Changeset{} = changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
Enum.reduce(opts, message, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
|> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
end
defp format_reason(reason) when is_binary(reason), do: reason
defp format_reason(%{message: message}) when is_binary(message), do: message
defp format_reason(%{guidance: guidance}) when is_binary(guidance), do: guidance
defp format_reason(reason), do: inspect(reason)
end

57
lib/bds/cli/install.ex Normal file
View File

@@ -0,0 +1,57 @@
defmodule BDS.CLI.Install do
@moduledoc """
Installs the `bds-cli` launcher into `~/.local/bin` (issue #25), used by
the install buttons in the GUI and TUI settings.
The installed file is a two-line shim exec'ing the release's
`cli/bin/bds-cli` launcher (shipped via `rel/overlays`), so the CLI always
runs the same release — and therefore the same settings and cache
database — as the installed application.
"""
@launcher_name "bds-cli"
@doc """
Writes the `~/.local/bin/bds-cli` shim. Returns `{:ok, installed_path}`,
or `{:error, :no_release}` when not running from a packaged release (dev
runs have no launcher to point at).
"""
@spec install(keyword()) :: {:ok, Path.t()} | {:error, :no_release | File.posix()}
def install(opts \\ []) do
bin_dir = Keyword.get(opts, :bin_dir, Path.expand("~/.local/bin"))
with {:ok, launcher} <- launcher_path(Keyword.get(opts, :release_root)) do
installed = Path.join(bin_dir, @launcher_name)
with :ok <- File.mkdir_p(bin_dir),
:ok <- File.write(installed, shim_script(launcher)),
:ok <- File.chmod(installed, 0o755) do
{:ok, installed}
end
end
end
@doc """
The release's CLI launcher script, resolved from `RELEASE_ROOT` (set by
release scripts) or the code root dir (the release root when running a
release). `{:error, :no_release}` when neither contains the launcher.
"""
@spec launcher_path(Path.t() | nil) :: {:ok, Path.t()} | {:error, :no_release}
def launcher_path(release_root \\ nil) do
[release_root, System.get_env("RELEASE_ROOT"), to_string(:code.root_dir())]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.map(&Path.join([&1, "cli", "bin", @launcher_name]))
|> Enum.find(&File.regular?/1)
|> case do
nil -> {:error, :no_release}
path -> {:ok, path}
end
end
defp shim_script(launcher) do
"""
#!/bin/sh
exec "#{launcher}" "$@"
"""
end
end

View File

@@ -2,6 +2,7 @@ defmodule BDS.Desktop.Automation do
@moduledoc false @moduledoc false
use GenServer use GenServer
require Logger
@ready_timeout 60_000 @ready_timeout 60_000
@request_timeout 30_000 @request_timeout 30_000
@@ -13,7 +14,9 @@ defmodule BDS.Desktop.Automation do
def stop_session(session) do def stop_session(session) do
GenServer.stop(session, :normal, @request_timeout) GenServer.stop(session, :normal, @request_timeout)
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed desktop automation stop_session exit: #{inspect(reason)}")
:ok
end end
def snapshot(session) do def snapshot(session) do
@@ -220,7 +223,12 @@ defmodule BDS.Desktop.Automation do
defp safe_driver_request(state, payload) do defp safe_driver_request(state, payload) do
driver_request(state, payload) driver_request(state, payload)
rescue rescue
_error -> :ok error ->
Logger.debug(
"swallowed desktop automation safe_driver_request error: #{inspect(error)} payload=#{inspect(payload)}"
)
:ok
end end
defp shutdown_driver(state) do defp shutdown_driver(state) do
@@ -368,7 +376,9 @@ defmodule BDS.Desktop.Automation do
defp safe_close_port(port) do defp safe_close_port(port) do
Port.close(port) Port.close(port)
catch catch
:error, _reason -> :ok :error, reason ->
Logger.debug("swallowed desktop automation safe_close_port error: #{inspect(reason)}")
:ok
end end
defp normalize_simple_reply("ok"), do: :ok defp normalize_simple_reply("ok"), do: :ok

View File

@@ -1,15 +1,20 @@
defmodule BDS.Desktop.DeepLink do defmodule BDS.Desktop.DeepLink do
@moduledoc """ @moduledoc """
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
shell (spec: script.allium `BlogmarkReceived`). live shell (spec: script.allium `BlogmarkReceived`).
On macOS the `BDS2.app` bundle registers `bds2://` as a custom URL scheme via 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` / the `CFBundleURLTypes` entry in its `Info.plist` (built by `BDS.MacBundle` /
`mix bds.bundle.macos`). When the browser bookmarklet navigates to `mix bds.bundle.macos`). When the browser bookmarklet navigates to
`bds2://new-post?title=&url=`, the OS launches/raises the app and `Desktop.Env` `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 relays erlang `:wx`'s `{:open_url, Url}` app event (where `Url` is the URL as a
events and forwards recognised `bds2://` links to the live shell over PubSub, charlist). This GenServer subscribes to those events and forwards recognised
where `BDS.Blogmark` turns them into draft posts. `bds2://` links to the live shell.
Cold start matters here: when the bookmarklet *launches* the app, the deep
link arrives during startup, before the LiveView shell's websocket has
connected. Such links are queued and replayed the moment a shell registers via
`attach/2`; while a shell is connected, links are delivered to it directly.
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
installs do not contend for the same registration. installs do not contend for the same registration.
@@ -19,8 +24,6 @@ defmodule BDS.Desktop.DeepLink do
require Logger require Logger
alias BDS.CliSync.Watcher
@scheme "bds2://" @scheme "bds2://"
def child_spec(opts) do def child_spec(opts) do
@@ -31,32 +34,69 @@ defmodule BDS.Desktop.DeepLink do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__)) GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end end
@impl true @doc """
def init(opts) do Registers the live shell `pid` as the deep-link sink and immediately replays
pubsub = Keyword.get(opts, :pubsub, BDS.PubSub) any links that arrived before a shell was connected. Future links are sent to
topic = Keyword.get(opts, :topic, Watcher.topic()) this pid until it dies, after which links queue again.
"""
subscribe_to_env() @spec attach(pid(), GenServer.server()) :: :ok
def attach(shell_pid, server \\ __MODULE__) when is_pid(shell_pid) do
{:ok, %{pubsub: pubsub, topic: topic}} GenServer.call(server, {:attach, shell_pid})
end end
# Desktop.Env delivers OS events as {event_name, args} tuples.
@impl true @impl true
def handle_info({:open_url, [url | _rest]}, state) when is_binary(url) do def init(_opts) do
{:noreply, route(url, state)} subscribe_to_env()
{:ok, %{shell: nil, pending: []}}
end
@impl true
def handle_call({:attach, pid}, _from, state) do
Process.monitor(pid)
state.pending
|> Enum.reverse()
|> Enum.each(&send(pid, {:blogmark_deep_link, &1}))
{:reply, :ok, %{state | shell: pid, pending: []}}
end
# erlang's :wx delivers macOS app events as `{:open_url, Url}` where `Url` is
# the URL *string itself* — a charlist on current OTP, not a list of URLs
# (see `wx:subscribe_events/0`). Desktop.Env forwards it unchanged, so the
# payload must be normalised to a binary before routing.
@impl true
def handle_info({:open_url, payload}, state) do
{:noreply, route(to_url(payload), state)}
end
def handle_info({:DOWN, _ref, :process, pid, _reason}, %{shell: pid} = state) do
{:noreply, %{state | shell: nil}}
end end
def handle_info(_message, state), do: {:noreply, state} def handle_info(_message, state), do: {:noreply, state}
defp route(url, state) do # Accept the charlist wx actually sends, a bare binary, or a [binary | _] list
if String.starts_with?(url, @scheme) do # (the shape Desktop.Env's docs imply), normalising all to a binary URL.
Phoenix.PubSub.broadcast(state.pubsub, state.topic, {:blogmark_deep_link, url}) defp to_url(value) when is_binary(value), do: value
else defp to_url([first | _]) when is_binary(first), do: first
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}") defp to_url(value) when is_list(value), do: List.to_string(value)
end defp to_url(_other), do: ""
defp route(url, state) do
cond do
not String.starts_with?(url, @scheme) ->
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
state state
is_pid(state.shell) and Process.alive?(state.shell) ->
send(state.shell, {:blogmark_deep_link, url})
state
true ->
# No shell connected yet (cold start) — hold the link until one attaches.
%{state | pending: [url | state.pending]}
end
end end
# Desktop.Env is only present when the wx desktop adapter is running. Guard the # Desktop.Env is only present when the wx desktop adapter is running. Guard the
@@ -66,7 +106,9 @@ defmodule BDS.Desktop.DeepLink do
try do try do
Desktop.Env.subscribe() Desktop.Env.subscribe()
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed deep_link Desktop.Env.subscribe exit: #{inspect(reason)}")
:ok
end end
end end

View File

@@ -0,0 +1,52 @@
defmodule BDS.Desktop.Dialogs do
@moduledoc false
# Native single-line text prompt, same osascript approach as
# BDS.Desktop.FilePicker. Returns {:ok, text} | :cancel | {:error, map}.
def prompt_text(message, default \\ "") when is_binary(message) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
:cancel
else
case :os.type() do
{:unix, :darwin} -> prompt_text_macos(message, default)
_other -> {:error, %{message: "Dialogs are only supported on macOS desktop"}}
end
end
end
def alert(title, message) when is_binary(title) and is_binary(message) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" or :os.type() != {:unix, :darwin} do
:ok
else
script = "display alert \"#{escape(title)}\" message \"#{escape(message)}\""
_ = System.cmd("osascript", ["-e", script], stderr_to_stdout: true)
:ok
end
end
defp prompt_text_macos(message, default) do
script =
"text returned of (display dialog \"#{escape(message)}\" default answer \"#{escape(default)}\")"
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
{output, 0} -> {:ok, String.trim(output)}
{output, _status} -> normalize_failure(output)
end
end
defp normalize_failure(output) do
message = String.trim(output)
if message == "" or String.contains?(String.downcase(message), "canceled") do
:cancel
else
{:error, %{message: message}}
end
end
defp escape(value) do
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
end
end

View File

@@ -17,19 +17,14 @@ defmodule BDS.Desktop.Endpoint do
plug(Plug.Static, plug(Plug.Static,
at: "/assets", at: "/assets",
from: {:bds, "priv/static/assets"}, from: {:bds, "priv/static/assets"},
only: ["app.css", "app.js"] only: ["app.css", "app.js", "monaco.css", "monaco.js", "monaco"]
)
plug(Plug.Static,
at: "/monaco",
from: {:bds, "priv/ui/monaco"},
only: ["vs"]
) )
plug(BDS.Desktop.Router) plug(BDS.Desktop.Router)
defp maybe_require_desktop_auth(conn, _opts) do defp maybe_require_desktop_auth(conn, _opts) do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] or
not BDS.Server.desktop_auth_required?() do
conn conn
else else
Desktop.Auth.call(conn, []) Desktop.Auth.call(conn, [])

View File

@@ -13,6 +13,7 @@ defmodule BDS.Desktop.Layouts do
<title><%= @page_title || "Blogging Desktop Server" %></title> <title><%= @page_title || "Blogging Desktop Server" %></title>
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} /> <meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
<link phx-track-static rel="stylesheet" href="/assets/app.css" /> <link phx-track-static rel="stylesheet" href="/assets/app.css" />
<link phx-track-static rel="stylesheet" href="/assets/monaco.css" />
</head> </head>
<body> <body>
<%= @inner_content %> <%= @inner_content %>

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.MainWindow do
use GenServer use GenServer
alias Desktop.Window alias Desktop.Window
require Logger
@window_id __MODULE__ @window_id __MODULE__
@server_name BDS.Desktop.MainWindow.Watcher @server_name BDS.Desktop.MainWindow.Watcher
@@ -22,7 +23,9 @@ defmodule BDS.Desktop.MainWindow do
def persist_now(timeout \\ 100) do def persist_now(timeout \\ 100) do
GenServer.call(@server_name, :persist_bounds_now, timeout) GenServer.call(@server_name, :persist_bounds_now, timeout)
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed main_window persist_now exit: #{inspect(reason)}")
:ok
end end
def window_options(extra_opts \\ []) do def window_options(extra_opts \\ []) do
@@ -169,10 +172,17 @@ defmodule BDS.Desktop.MainWindow do
end end
end) end)
rescue rescue
ErlangError -> nil error in ErlangError ->
FunctionClauseError -> nil Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
nil
error in FunctionClauseError ->
Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
nil
catch catch
:exit, _reason -> nil :exit, reason ->
Logger.debug("swallowed main_window restore_bounds exit: #{inspect(reason)}")
nil
end end
end end

View File

@@ -8,16 +8,45 @@ defmodule BDS.Desktop.MediaController do
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
@web_image_types ~w(image/jpeg image/png image/webp image/gif image/svg+xml image/avif)
def thumbnail(conn, %{"media_id" => media_id} = params) do def thumbnail(conn, %{"media_id" => media_id} = params) do
case active_media_thumbnail(media_id, Map.get(params, "size")) do send_media(conn, active_media_thumbnail(media_id, Map.get(params, "size")))
{:ok, content_type, path} -> end
def file(conn, %{"media_id" => media_id}) do
send_media(conn, active_media_file(media_id))
end
defp send_media(conn, {:ok, content_type, path}) do
conn conn
|> Plug.Conn.put_resp_content_type(content_type) |> Plug.Conn.put_resp_content_type(content_type)
|> Plug.Conn.send_file(200, path) |> Plug.Conn.send_file(200, path)
:error ->
send_resp(conn, 404, "not found")
end end
defp send_media(conn, :error), do: send_resp(conn, 404, "not found")
# Serves the original image at full resolution; formats browsers cannot
# render inline fall back to the large thumbnail.
defp active_media_file(media_id) do
with %{} = project <- Projects.get_active_project(),
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
true <- media.project_id == project.id,
true <- media.mime_type in @web_image_types,
absolute_path = Path.join(Projects.project_data_dir(project), media.file_path),
true <- File.exists?(absolute_path) do
{:ok, media.mime_type, absolute_path}
else
_other -> active_media_thumbnail(media_id, "large")
end
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
:error
end end
defp active_media_thumbnail(media_id, size) do defp active_media_thumbnail(media_id, size) do

View File

@@ -25,12 +25,7 @@ defmodule BDS.Desktop.MenuBar do
def mount(menu) do def mount(menu) do
UILocale.put(ShellData.ui_language()) UILocale.put(ShellData.ui_language())
{:ok, {:ok, Desktop.Menu.assign(menu, :groups, groups(native_menu_opts()))}
Desktop.Menu.assign(
menu,
:groups,
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
)}
end end
@impl true @impl true
@@ -72,6 +67,35 @@ defmodule BDS.Desktop.MenuBar do
{:noreply, menu} {:noreply, menu}
end end
def handle_event("connect_server", menu) do
prompt =
dgettext("ui", "Server address (user@host or user@host:port), public-key auth:")
with {:ok, target} <- BDS.Desktop.Dialogs.prompt_text(prompt, "user@host"),
{:ok, url} <- BDS.Desktop.RemoteConnection.connect(target) do
Window.show(BDS.Desktop.MainWindow.window_id(), url)
else
:cancel ->
:ok
{:error, reason} ->
BDS.Desktop.Dialogs.alert(
dgettext("ui", "Connect to Server"),
dgettext("ui", "Connection failed: %{reason}",
reason: BDS.Desktop.RemoteConnection.format_reason(reason)
)
)
end
{:noreply, menu}
end
def handle_event("disconnect_server", menu) do
:ok = BDS.Desktop.RemoteConnection.disconnect()
Window.show(BDS.Desktop.MainWindow.window_id(), BDS.Desktop.url())
{:noreply, menu}
end
def handle_event("open_data_folder", menu) do def handle_event("open_data_folder", menu) do
_ = BDS.Desktop.ShellCommands.execute("open_data_folder") _ = BDS.Desktop.ShellCommands.execute("open_data_folder")
{:noreply, menu} {:noreply, menu}
@@ -82,6 +106,24 @@ defmodule BDS.Desktop.MenuBar do
{:noreply, menu} {:noreply, menu}
end end
def handle_event("minimize", menu) do
with_frame(&:wxTopLevelWindow.iconize(&1, iconize: true))
{:noreply, menu}
end
def handle_event("zoom", menu) do
with_frame(fn frame ->
:wxTopLevelWindow.maximize(frame, maximize: not :wxTopLevelWindow.isMaximized(frame))
end)
{:noreply, menu}
end
def handle_event("bring_all_to_front", menu) do
with_frame(&:wxWindow.raise/1)
{:noreply, menu}
end
def handle_event(command, menu) do def handle_event(command, menu) do
dispatch_shell_menu_action(command) dispatch_shell_menu_action(command)
{:noreply, menu} {:noreply, menu}
@@ -91,12 +133,7 @@ defmodule BDS.Desktop.MenuBar do
def handle_info({:set_ui_locale, locale}, menu) do def handle_info({:set_ui_locale, locale}, menu) do
UILocale.put(locale) UILocale.put(locale)
{:noreply, {:noreply, Desktop.Menu.assign(menu, :groups, groups(native_menu_opts()))}
Desktop.Menu.assign(
menu,
:groups,
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
)}
end end
def handle_info(_, menu) do def handle_info(_, menu) do
@@ -123,6 +160,32 @@ defmodule BDS.Desktop.MenuBar do
end end
end end
defp native_menu_opts do
[
dev_mode?: Application.get_env(:bds, :dev_routes, false),
window_menu?: OS.type() == MacOS
]
end
defp with_frame(fun) do
case frame() do
nil ->
:ok
frame ->
:wx.set_env(Desktop.Env.wx_env())
fun.(frame)
end
end
defp frame do
try do
Window.frame(BDS.Desktop.MainWindow.window_id())
catch
:exit, _ -> nil
end
end
defp normalize_item(%{separator: true}), do: %{separator: true} defp normalize_item(%{separator: true}), do: %{separator: true}
defp normalize_item(item) do defp normalize_item(item) do
@@ -145,12 +208,15 @@ defmodule BDS.Desktop.MenuBar do
defp group_label(:edit), do: dgettext("ui", "Edit") defp group_label(:edit), do: dgettext("ui", "Edit")
defp group_label(:view), do: dgettext("ui", "View") defp group_label(:view), do: dgettext("ui", "View")
defp group_label(:blog), do: dgettext("ui", "Blog") defp group_label(:blog), do: dgettext("ui", "Blog")
defp group_label(:window), do: dgettext("ui", "Window")
defp group_label(:help), do: dgettext("ui", "Help") defp group_label(:help), do: dgettext("ui", "Help")
defp item_label(:new_post), do: dgettext("ui", "New Post") defp item_label(:new_post), do: dgettext("ui", "New Post")
defp item_label(:import_media), do: dgettext("ui", "Import Media") defp item_label(:import_media), do: dgettext("ui", "Import Media")
defp item_label(:save), do: dgettext("ui", "Save") defp item_label(:save), do: dgettext("ui", "Save")
defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser") defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
defp item_label(:connect_server), do: dgettext("ui", "Connect to Server…")
defp item_label(:disconnect_server), do: dgettext("ui", "Disconnect from Server")
defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder") defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
defp item_label(:close_tab), do: dgettext("ui", "Close Tab") defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
defp item_label(:quit), do: dgettext("ui", "Quit") defp item_label(:quit), do: dgettext("ui", "Quit")
@@ -188,8 +254,12 @@ defmodule BDS.Desktop.MenuBar do
defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations") defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations")
defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts") defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts")
defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site") defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site")
defp item_label(:force_render_site), do: dgettext("ui", "Force Render Site")
defp item_label(:validate_site), do: dgettext("ui", "Validate Site") defp item_label(:validate_site), do: dgettext("ui", "Validate Site")
defp item_label(:upload_site), do: dgettext("ui", "Upload Site") defp item_label(:upload_site), do: dgettext("ui", "Upload Site")
defp item_label(:minimize), do: dgettext("ui", "Minimize")
defp item_label(:zoom), do: dgettext("ui", "Zoom")
defp item_label(:bring_all_to_front), do: dgettext("ui", "Bring All to Front")
defp item_label(:about), do: dgettext("ui", "About") defp item_label(:about), do: dgettext("ui", "About")
defp item_label(:documentation), do: dgettext("ui", "Documentation") defp item_label(:documentation), do: dgettext("ui", "Documentation")
defp item_label(:api_documentation), do: dgettext("ui", "API Documentation") defp item_label(:api_documentation), do: dgettext("ui", "API Documentation")

View File

@@ -0,0 +1,180 @@
defmodule BDS.Desktop.RemoteConnection do
@moduledoc """
GUI remote mode (issue #26, phase 5).
Connects the desktop app to a headless bDS2 server over the same SSH
channel (and the same public keys) the TUI uses: `:ssh.connect` with
public-key auth, then an OTP TCP/IP tunnel to the server's loopback HTTP
endpoint. The webview then loads the local tunnel end — the server never
exposes HTTP.
Client key material lives in the same private `ssh/` directory next to
the database (`BDS.Server.ssh_dir/0`): an `id_ed25519`/`id_rsa` identity
and a `known_hosts` written on first connect.
"""
use GenServer
use Gettext, backend: BDS.Gettext
require Logger
@default_ssh_port 2222
@tunnel_timeout_ms 10_000
def start_link(opts \\ []) do
case Keyword.pop(opts, :name, __MODULE__) do
{nil, init_opts} -> GenServer.start_link(__MODULE__, init_opts)
{name, init_opts} -> GenServer.start_link(__MODULE__, init_opts, name: name)
end
end
@doc """
Parses `user@host[:port]` into connection parameters. The port is the
server's SSH port (default #{@default_ssh_port}).
"""
@spec parse_target(String.t()) ::
{:ok, %{user: String.t(), host: String.t(), port: pos_integer()}}
| {:error, :invalid_target}
def parse_target(target) when is_binary(target) do
with [user, rest] when user != "" <- String.split(String.trim(target), "@", parts: 2),
{:ok, host, port} <- parse_host_port(rest) do
{:ok, %{user: user, host: host, port: port}}
else
_other -> {:error, :invalid_target}
end
end
defp parse_host_port(rest) do
case String.split(rest, ":", parts: 2) do
[host] when host != "" ->
{:ok, host, @default_ssh_port}
[host, port] when host != "" ->
case Integer.parse(port) do
{number, ""} when number > 0 -> {:ok, host, number}
_other -> :error
end
_other ->
:error
end
end
@doc "Connects and tunnels; returns the local URL the webview should load."
@spec connect(GenServer.server(), String.t()) :: {:ok, String.t()} | {:error, term()}
def connect(server \\ __MODULE__, target) do
GenServer.call(server, {:connect, target}, @tunnel_timeout_ms + 5_000)
end
@spec disconnect(GenServer.server()) :: :ok
def disconnect(server \\ __MODULE__) do
GenServer.call(server, :disconnect)
end
@spec status(GenServer.server()) :: :disconnected | {:connected, String.t(), String.t()}
def status(server \\ __MODULE__) do
GenServer.call(server, :status)
end
@doc "Human-readable connection error for menu/shell error reporting."
@spec format_reason(term()) :: String.t()
def format_reason(:invalid_target) do
dgettext("ui", "Use the form user@host or user@host:port.")
end
def format_reason(%{message: message}) when is_binary(message), do: message
def format_reason(reason), do: inspect(reason)
## GenServer callbacks
@impl true
def init(opts) do
overrides = Keyword.get(opts, :test_overrides, [])
{:ok,
%{
conn: nil,
monitor: nil,
target: nil,
url: nil,
connect_fun: Keyword.get(overrides, :connect_fun, &:ssh.connect/3),
tunnel_fun: Keyword.get(overrides, :tunnel_fun, &:ssh.tcpip_tunnel_to_server/6),
close_fun: Keyword.get(overrides, :close_fun, &:ssh.close/1),
ssh_dir_fun: Keyword.get(overrides, :ssh_dir_fun, &BDS.Server.ssh_dir/0)
}}
end
@impl true
def handle_call({:connect, target}, _from, state) do
state = close_current(state)
with {:ok, parsed} <- parse_target(target),
{:ok, conn} <- do_connect(state, parsed),
{:ok, local_port} <- do_tunnel(state, conn) do
url = "http://127.0.0.1:#{local_port}/"
{:reply, {:ok, url},
%{
state
| conn: conn,
monitor: Process.monitor(conn),
target: String.trim(target),
url: url
}}
else
{:error, reason} = error ->
Logger.warning("Remote connection to #{inspect(target)} failed: #{inspect(reason)}")
{:reply, error, state}
end
end
def handle_call(:disconnect, _from, state) do
{:reply, :ok, close_current(state)}
end
def handle_call(:status, _from, %{conn: nil} = state) do
{:reply, :disconnected, state}
end
def handle_call(:status, _from, state) do
{:reply, {:connected, state.target, state.url}, state}
end
@impl true
def handle_info({:DOWN, monitor, :process, _pid, reason}, %{monitor: monitor} = state) do
Logger.warning("Remote SSH connection lost: #{inspect(reason)}")
{:noreply, %{state | conn: nil, monitor: nil, target: nil, url: nil}}
end
def handle_info(_message, state), do: {:noreply, state}
## Internal helpers
defp do_connect(state, %{user: user, host: host, port: port}) do
ssh_dir = BDS.Server.ensure_ssh_dir!(state.ssh_dir_fun.())
state.connect_fun.(String.to_charlist(host), port,
user: String.to_charlist(user),
user_dir: String.to_charlist(ssh_dir),
auth_methods: ~c"publickey",
# First connect records the host key in <ssh_dir>/known_hosts;
# later connects verify against it (trust-on-first-use).
silently_accept_hosts: true,
connect_timeout: @tunnel_timeout_ms
)
end
defp do_tunnel(state, conn) do
remote_port = Application.get_env(:bds, :desktop)[:port] || 4010
state.tunnel_fun.(conn, ~c"127.0.0.1", 0, ~c"127.0.0.1", remote_port, @tunnel_timeout_ms)
end
defp close_current(%{conn: nil} = state), do: state
defp close_current(state) do
if state.monitor, do: Process.demonitor(state.monitor, [:flush])
_ = state.close_fun.(state.conn)
%{state | conn: nil, monitor: nil, target: nil, url: nil}
end
end

View File

@@ -19,6 +19,7 @@ defmodule BDS.Desktop.Router do
get("/health", HealthController, :show) get("/health", HealthController, :show)
get("/media-thumbnail/:media_id", MediaController, :thumbnail) get("/media-thumbnail/:media_id", MediaController, :thumbnail)
get("/media-file/:media_id", MediaController, :file)
live_session :desktop_shell, live_session :desktop_shell,
root_layout: {BDS.Desktop.Layouts, :root} do root_layout: {BDS.Desktop.Layouts, :root} do

View File

@@ -120,7 +120,7 @@ defmodule BDS.Desktop.ShellCommands do
"rebuild_embedding_index", "rebuild_embedding_index",
"Rebuild Embedding Index", "Rebuild Embedding Index",
"Embeddings", "Embeddings",
fn report -> rebuild_embedding_index_work(project, report) end fn report -> Maintenance.rebuild_embedding_index(project.id, on_progress: report) end
) )
end end
@@ -196,6 +196,14 @@ defmodule BDS.Desktop.ShellCommands do
end) end)
end end
defp dispatch("rebuild_media_links", project, _params) do
queue_task(project, "rebuild_media_links", "Rebuild Media Links", "Maintenance", fn report ->
{:ok, result} = BDS.Media.rebuild_media_links(project.id, on_progress: report)
report.(1.0, "Media links rebuilt")
%{project_id: project.id, counts: %{media_links: result.links}}
end)
end
defp dispatch("regenerate_missing_thumbnails", project, _params) do defp dispatch("regenerate_missing_thumbnails", project, _params) do
queue_task( queue_task(
project, project,
@@ -213,7 +221,7 @@ defmodule BDS.Desktop.ShellCommands do
defp dispatch("rebuild_database", project, _params) do defp dispatch("rebuild_database", project, _params) do
group_id = task_group_id("rebuild_database") group_id = task_group_id("rebuild_database")
attrs = %{group_id: group_id, group_name: "Maintenance"} attrs = %{group_id: group_id, group_name: "Maintenance"}
[first_step | remaining_steps] = rebuild_database_steps(project) [first_step | remaining_steps] = Maintenance.full_rebuild_steps(project.id)
{:ok, posts_task} = {:ok, posts_task} =
Tasks.submit_task(first_step.name, first_step.work, attrs) Tasks.submit_task(first_step.name, first_step.work, attrs)
@@ -239,18 +247,13 @@ defmodule BDS.Desktop.ShellCommands do
end end
defp dispatch("generate_sitemap", project, _params) do defp dispatch("generate_sitemap", project, _params) do
queue_task(project, "generate_sitemap", "Generate Site", "Generation", fn report -> queue_render_site(project, "generate_sitemap", "Render Site", [])
{:ok, generation} = end
Generation.generate_site(project.id, @site_sections, on_progress: report)
report.(1.0, "Generated site output") # Forced variant of the site render: ignores the stored content hashes and
# rewrites every output file, repairing any drift on disk.
%{ defp dispatch("force_render_site", project, _params) do
project_id: project.id, queue_render_site(project, "force_render_site", "Force Render Site", force: true)
sections: generation.sections,
generated_count: length(generation.generated_files)
}
end)
end end
defp dispatch("validate_site", project, _params) do defp dispatch("validate_site", project, _params) do
@@ -262,6 +265,42 @@ defmodule BDS.Desktop.ShellCommands do
end) end)
end end
defp dispatch("apply_site_validation", project, params) do
validation_report = normalize_apply_validation_report(BDS.MapUtils.attr(params, :report, %{}))
group_id = task_group_id("apply_site_validation")
attrs = %{group_id: group_id, group_name: "Apply Site Validation"}
{:ok, prepare_task} =
Tasks.submit_task(
"Prepare Validation Apply",
fn report ->
{:ok, preparation} =
Generation.prepare_validation_apply(project.id, validation_report,
on_progress: report
)
Map.put(preparation, :project_id, project.id)
end,
attrs
)
Task.start(fn ->
run_apply_validation_sequence(project, validation_report, group_id, attrs, prepare_task.id)
end)
{:ok,
%{
kind: "task_queued",
action: "apply_site_validation",
title: "Apply Site Validation",
message: "Apply Site Validation tasks queued",
project_id: project.id,
task_id: prepare_task.id,
task_group_id: group_id,
panel_tab: "tasks"
}}
end
defp dispatch("metadata_diff", project, _params) do defp dispatch("metadata_diff", project, _params) do
queue_task(project, "metadata_diff", "Metadata Diff", "Maintenance", fn report -> queue_task(project, "metadata_diff", "Metadata Diff", "Maintenance", fn report ->
{:ok, metadata_diff} = Maintenance.metadata_diff(project.id, on_progress: report) {:ok, metadata_diff} = Maintenance.metadata_diff(project.id, on_progress: report)
@@ -439,6 +478,207 @@ defmodule BDS.Desktop.ShellCommands do
end end
end end
defp queue_render_site(project, action, title, render_opts) do
group_id = task_group_id(action)
attrs = %{group_id: group_id, group_name: title}
{:ok, first_task} =
Tasks.submit_task(
section_task_name(:core),
fn report ->
{:ok, _result} =
Generation.render_site_section(
project.id,
:core,
[on_progress: report] ++ render_opts
)
report.(1.0, section_complete_message(:core))
:ok
end,
attrs
)
Task.start(fn -> run_render_site_sequence(project, group_id, attrs, render_opts) end)
{:ok,
%{
kind: "task_queued",
action: action,
title: title,
message: title <> " tasks queued",
project_id: project.id,
task_id: first_task.id,
task_group_id: group_id,
panel_tab: "tasks"
}}
end
defp run_render_site_sequence(project, group_id, attrs, render_opts) do
remaining_sections = [:single, :category, :tag, :date]
Enum.each(remaining_sections, fn section ->
{:ok, _task} =
Tasks.submit_task(
section_task_name(section),
fn report ->
{:ok, _result} =
Generation.render_site_section(
project.id,
section,
[on_progress: report] ++ render_opts
)
report.(1.0, section_complete_message(section))
:ok
end,
attrs
)
end)
section_names = Enum.map([:core | remaining_sections], &section_task_name/1)
with :ok <- wait_for_group_phase(group_id, section_names, @rebuild_phase_timeout) do
{:ok, _task} =
Tasks.submit_task(
"Build Search Index",
fn report ->
{:ok, _result} =
Generation.build_site_search_index(
project.id,
[on_progress: report] ++ render_opts
)
report.(1.0, "Build Search Index complete")
:ok
end,
attrs
)
wait_for_group_phase(group_id, ["Build Search Index"], @rebuild_phase_timeout)
end
:ok
end
defp run_apply_validation_sequence(project, validation_report, group_id, attrs, prepare_task_id) do
with :ok <-
wait_for_group_phase(group_id, ["Prepare Validation Apply"], @rebuild_phase_timeout),
{:ok, preparation} <- completed_task_result(prepare_task_id),
:ok <-
run_apply_validation_render_tasks(
project,
validation_report,
group_id,
attrs,
preparation
) do
if validation_apply_changed?(preparation) do
:ok = run_apply_validation_refresh_task(project, group_id, attrs, :calendar)
end
submit_apply_validation_check_task(project, attrs)
else
_other -> :ok
end
end
defp run_apply_validation_render_tasks(project, validation_report, group_id, attrs, preparation) do
sections = Map.get(preparation, :sections_to_render, [])
task_names = Enum.map(sections, &section_task_name/1)
Enum.each(sections, fn section ->
task_name = section_task_name(section)
{:ok, _task} =
Tasks.submit_task(
task_name,
fn report ->
{:ok, _result} =
Generation.apply_validation_section(project.id, validation_report, section,
on_progress: report
)
report.(1.0, section_complete_message(section))
:ok
end,
attrs
)
end)
case task_names do
[] -> :ok
names -> wait_for_group_phase(group_id, names, @rebuild_phase_timeout)
end
end
defp run_apply_validation_refresh_task(project, group_id, attrs, kind) do
task_name = apply_validation_refresh_task_name(kind)
{:ok, _task} =
Tasks.submit_task(
task_name,
fn report ->
{:ok, _result} =
Generation.refresh_validation_ancillary_outputs(project.id, kind, on_progress: report)
report.(1.0, apply_validation_refresh_complete_message(kind))
:ok
end,
attrs
)
wait_for_group_phase(group_id, [task_name], @rebuild_phase_timeout)
end
defp submit_apply_validation_check_task(project, attrs) do
{:ok, _task} =
Tasks.submit_task(
"Validate Site",
fn report ->
{:ok, validation} =
Generation.validate_site(project.id, @site_sections, on_progress: report)
site_validation_result(project.id, validation)
end,
attrs
)
:ok
end
defp completed_task_result(task_id) do
case Tasks.get_task(task_id) do
%{status: :completed, result: result} when is_map(result) -> {:ok, result}
%{status: :failed, error: error} -> {:error, error}
_other -> {:error, :task_result_unavailable}
end
end
defp validation_apply_changed?(preparation) do
Map.get(preparation, :sections_to_render, []) != [] or
Map.get(preparation, :deleted_url_count, 0) > 0
end
defp section_task_name(:core), do: "Render Site Core"
defp section_task_name(:single), do: "Render Single Posts"
defp section_task_name(:category), do: "Render Category Archives"
defp section_task_name(:tag), do: "Render Tag Archives"
defp section_task_name(:date), do: "Render Date Archives"
defp section_complete_message(:core), do: "Render Site Core complete"
defp section_complete_message(:single), do: "Render Single Posts complete"
defp section_complete_message(:category),
do: "Render Category Archives complete"
defp section_complete_message(:tag), do: "Render Tag Archives complete"
defp section_complete_message(:date), do: "Render Date Archives complete"
defp apply_validation_refresh_task_name(:calendar), do: "Regenerate Calendar"
defp apply_validation_refresh_complete_message(:calendar), do: "Regenerate Calendar complete"
defp translation_fill_enabled?(metadata) do defp translation_fill_enabled?(metadata) do
([metadata.main_language] ++ metadata.blog_languages) ([metadata.main_language] ++ metadata.blog_languages)
|> Enum.map(fn language -> |> Enum.map(fn language ->
@@ -452,102 +692,6 @@ defmodule BDS.Desktop.ShellCommands do
|> length() > 1 |> length() > 1
end end
defp rebuild_database_steps(project) do
[
%{
name: "Rebuild Posts From Files",
work: fn report ->
{:ok, posts} =
Maintenance.rebuild_from_filesystem(project.id, "post",
on_progress: report,
rebuild_embeddings: false
)
report.(1.0, "Post rebuild complete")
%{project_id: project.id, counts: %{posts: length(posts)}}
end
},
%{
name: "Rebuild Media From Files",
work: fn report ->
{:ok, media} =
Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
report.(1.0, "Media rebuild complete")
%{project_id: project.id, counts: %{media: length(media)}}
end
},
%{
name: "Rebuild Scripts From Files",
work: fn report ->
{:ok, scripts} =
Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
report.(1.0, "Script rebuild complete")
%{project_id: project.id, counts: %{scripts: length(scripts)}}
end
},
%{
name: "Rebuild Templates From Files",
work: fn report ->
{:ok, templates} =
Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
report.(1.0, "Template rebuild complete")
%{project_id: project.id, counts: %{templates: length(templates)}}
end
},
%{
name: "Rebuild Post Links",
work: fn report ->
:ok = Posts.rebuild_post_links(project.id, on_progress: report)
report.(1.0, "Post links rebuilt")
%{project_id: project.id}
end
},
%{
name: "Regenerate Missing Thumbnails",
work: fn report ->
result = BDS.Media.regenerate_missing_thumbnails(project.id, on_progress: report)
report.(1.0, "Missing thumbnails regenerated")
Map.put(result, :project_id, project.id)
end
},
%{
name: "Rebuild Embedding Index",
work: fn report -> rebuild_embedding_index_work(project, report) end
}
]
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")
%{
project_id: project.id,
rebuilt_post_ids: rebuilt_post_ids,
rebuilt_count: length(rebuilt_post_ids)
}
{:error, reason} ->
{:error, embedding_error_message(reason)}
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
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do
@@ -640,6 +784,20 @@ defmodule BDS.Desktop.ShellCommands do
"http://#{server.host}:#{server.port}/" "http://#{server.host}:#{server.port}/"
end end
defp normalize_apply_validation_report(report) when is_map(report) do
%{
sitemap_path: BDS.MapUtils.attr(report, :sitemap_path),
sitemap_changed: BDS.MapUtils.attr(report, :sitemap_changed, false),
missing_url_paths: BDS.MapUtils.attr(report, :missing_url_paths, []),
extra_url_paths: BDS.MapUtils.attr(report, :extra_url_paths, []),
updated_post_url_paths: BDS.MapUtils.attr(report, :updated_post_url_paths, []),
expected_url_count: BDS.MapUtils.attr(report, :expected_url_count, 0),
existing_html_url_count: BDS.MapUtils.attr(report, :existing_html_url_count, 0)
}
end
defp normalize_apply_validation_report(_report), do: %{}
defp normalize_site_validation(report) do defp normalize_site_validation(report) do
%{ %{
sitemap_path: report.sitemap_path, sitemap_path: report.sitemap_path,

View File

@@ -91,32 +91,16 @@ defmodule BDS.Desktop.ShellData do
end end
end end
def assistant_cards do # Accepts the caller's already-resolved locale so hot render paths never
[ # re-read the ui.language setting from the database.
%{ def editor_meta(task_status, ui_language \\ nil) do
label: dgettext("ui", "Offline Gate"),
text: dgettext("ui", "Automatic AI actions stay gated by airplane mode.")
},
%{
label: dgettext("ui", "Filesystem Sync"),
text:
dgettext("ui", "Metadata flush, diffing, and rebuild hooks still need editor wiring.")
},
%{
label: dgettext("ui", "Desktop Runtime"),
text: dgettext("ui", "The app window is now served from LiveView state.")
}
]
end
def editor_meta(task_status) do
[ [
%{ %{
label: dgettext("ui", "Status"), label: dgettext("ui", "Status"),
value: task_status.running_task_message || dgettext("ui", "Idle") value: task_status.running_task_message || dgettext("ui", "Idle")
}, },
%{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")}, %{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")},
%{label: dgettext("ui", "Main Language"), value: ui_language()} %{label: dgettext("ui", "Main Language"), value: ui_language || ui_language()}
] ]
end end
@@ -125,7 +109,7 @@ defmodule BDS.Desktop.ShellData do
post_count: dashboard.post_stats.total_posts, post_count: dashboard.post_stats.total_posts,
media_count: dashboard.media_stats.media_count, media_count: dashboard.media_stats.media_count,
theme_badge: "desktop-shell", theme_badge: "desktop-shell",
ui_language: Keyword.get(opts, :ui_language, ui_language()), ui_language: Keyword.get_lazy(opts, :ui_language, &ui_language/0),
offline_mode: Keyword.get(opts, :offline_mode, true), offline_mode: Keyword.get(opts, :offline_mode, true),
running_task_message: task_status.running_task_message, running_task_message: task_status.running_task_message,
running_task_overflow: task_status.running_task_overflow, running_task_overflow: task_status.running_task_overflow,

View File

@@ -6,22 +6,25 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML import Phoenix.HTML
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata} alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
alias BDS.CliSync.Watcher
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData, UILocale} alias BDS.Desktop.{FilePicker, FolderPicker, ShellData, UILocale}
alias BDS.Desktop.ShellLive.{ alias BDS.Desktop.ShellLive.{
Bridges, Bridges,
ChatEditor, ChatEditor,
GalleryImport, GalleryImport,
GitHandler,
GitRun,
ImportEditor, ImportEditor,
MediaEditor, MediaEditor,
MenuEditor, MenuEditor,
MiscEditor, MiscEditor,
NativeMenuEvents,
OverlayManager, OverlayManager,
ScriptEditor, ScriptEditor,
SettingsEditor, SettingsEditor,
SidebarDelete, SidebarDelete,
TagsEditor, TagsEditor,
TabActions,
TemplateEditor TemplateEditor
} }
@@ -29,25 +32,20 @@ defmodule BDS.Desktop.ShellLive do
alias BDS.Desktop.ShellLive.PostEditor alias BDS.Desktop.ShellLive.PostEditor
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
alias BDS.Desktop.ShellLive.SidebarEvents alias BDS.Desktop.ShellLive.SidebarEvents
alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState
alias BDS.Desktop.ShellLive.{ alias BDS.Desktop.ShellLive.{
ChatSurface, ChatSurface,
ContentState,
Layout, Layout,
PanelRenderer,
SessionUtil, SessionUtil,
ShellCommandRunner, ShellCommandRunner,
SidebarCreate, SidebarCreate,
SocketState,
TabHelpers, TabHelpers,
TaskLocalization, TitlebarMenu,
TitlebarMenu UrlState
} }
import TaskLocalization,
only: [
localize_task_status: 2
]
import TabHelpers, import TabHelpers,
only: [ only: [
tab_id_for_route: 2, tab_id_for_route: 2,
@@ -65,7 +63,6 @@ defmodule BDS.Desktop.ShellLive do
def refresh_interval, do: @refresh_interval def refresh_interval, do: @refresh_interval
@output_entry_limit 20
@sidebar_filter_events [ @sidebar_filter_events [
"toggle_sidebar_filters", "toggle_sidebar_filters",
"toggle_sidebar_archive", "toggle_sidebar_archive",
@@ -86,6 +83,8 @@ defmodule BDS.Desktop.ShellLive do
@git_action_events ["git_fetch", "git_pull", "git_push", "git_prune_lfs"] @git_action_events ["git_fetch", "git_pull", "git_push", "git_prune_lfs"]
@native_menu_events NativeMenuEvents.events()
@layout_menu_actions MapSet.new([ @layout_menu_actions MapSet.new([
:toggle_sidebar, :toggle_sidebar,
:toggle_panel, :toggle_panel,
@@ -109,7 +108,9 @@ defmodule BDS.Desktop.ShellLive do
:quit, :quit,
:view_on_github, :view_on_github,
:report_issue, :report_issue,
:about :about,
:connect_server,
:disconnect_server
]) ])
@runtime_menu_actions MapSet.new([ @runtime_menu_actions MapSet.new([
:undo, :undo,
@@ -139,7 +140,9 @@ defmodule BDS.Desktop.ShellLive do
|> MapSet.union( |> MapSet.union(
MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates]) MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates])
) )
|> MapSet.union(MapSet.new([:generate_sitemap, :validate_site, :upload_site])) |> MapSet.union(
MapSet.new([:generate_sitemap, :force_render_site, :validate_site, :upload_site])
)
end end
embed_templates("shell_live/*") embed_templates("shell_live/*")
@@ -149,7 +152,9 @@ defmodule BDS.Desktop.ShellLive do
connected = connected?(socket) connected = connected?(socket)
if connected do if connected do
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic()) # Entity + settings events from every source: in-app contexts, other
# connected clients (GUI or TUI), and the CLI sync watcher.
:ok = BDS.Events.subscribe()
Process.send_after(self(), :refresh_task_status, @refresh_interval) Process.send_after(self(), :refresh_task_status, @refresh_interval)
end end
@@ -175,12 +180,14 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:chat_editor_request_refs, %{}) |> assign(:chat_editor_request_refs, %{})
|> assign(:file_picker_task, nil) |> assign(:file_picker_task, nil)
|> assign(:shell_overlay, nil) |> assign(:shell_overlay, nil)
|> assign(:git_run, nil)
|> assign(:output_entries, []) |> assign(:output_entries, [])
|> assign(:collapsed_task_groups, MapSet.new())
|> assign(:panel_post_links, %{backlinks: [], outlinks: []}) |> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|> assign(:panel_git_entries, []) |> assign(:panel_git_entries, [])
|> assign(:auto_save_timers, %{}) |> assign(:auto_save_timers, %{})
|> reload_shell(workbench) |> reload_shell(workbench)
|> apply_url_params(params) |> UrlState.apply_params(params)
|> tap(&sync_menu_bar_locale/1)} |> tap(&sync_menu_bar_locale/1)}
end end
@@ -208,7 +215,7 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, {:noreply,
socket socket
|> refresh_sidebar(workbench) |> refresh_sidebar(workbench)
|> push_url_state()} |> UrlState.push()}
end end
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
@@ -220,6 +227,38 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, refresh_layout(socket, workbench)} {:noreply, refresh_layout(socket, workbench)}
end end
# Copy-all for the Output panel: entries are stored newest-first, so flip
# them back to chronological order before joining (old-app behavior).
def handle_event("copy_output", _params, socket) do
text =
socket.assigns.output_entries
|> Enum.reverse()
|> Enum.map_join("\n\n", & &1.message)
{:noreply, push_event(socket, "clipboard", %{text: text})}
end
def handle_event("toggle_task_group", %{"group" => group_id}, socket) do
collapsed = socket.assigns[:collapsed_task_groups] || MapSet.new()
collapsed =
if MapSet.member?(collapsed, group_id),
do: MapSet.delete(collapsed, group_id),
else: MapSet.put(collapsed, group_id)
{:noreply, assign(socket, :collapsed_task_groups, collapsed)}
end
def handle_event("cancel_task", %{"id" => task_id}, socket) do
_ = BDS.Tasks.cancel_task(task_id)
task_status =
BDS.Tasks.status_snapshot()
|> BDS.Desktop.ShellLive.TaskLocalization.localize_task_status(socket.assigns.page_language)
{:noreply, assign(socket, :task_status, task_status)}
end
def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
{:noreply, open_sidebar_item(socket, params, :preview)} {:noreply, open_sidebar_item(socket, params, :preview)}
end end
@@ -241,17 +280,34 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event(event, _params, socket) when event in @git_action_events do def handle_event(event, _params, socket) when event in @git_action_events do
{:noreply, run_git_action(socket, event)} if GitRun.network_event?(event) do
{:noreply, GitRun.start(socket, event, current_project_id(socket))}
else
{:noreply, GitHandler.run_action(socket, event)}
end end
end
def handle_event("git_run_close", _params, socket) do
socket = GitRun.close(socket)
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
end
def handle_event("git_run_keydown", %{"key" => "Escape"}, socket) do
socket = GitRun.close(socket)
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
end
def handle_event("git_run_keydown", _params, socket), do: {:noreply, socket}
def handle_event("git_commit", params, socket) do def handle_event("git_commit", params, socket) do
message = params |> get_in(["git", "message"]) |> to_string() |> String.trim() message = params |> get_in(["git", "message"]) |> to_string() |> String.trim()
{:noreply, commit_git(socket, message)}
{:noreply, GitHandler.commit(socket, message)}
end end
def handle_event("git_initialize", params, socket) do def handle_event("git_initialize", params, socket) do
remote_url = params |> get_in(["git", "remote_url"]) |> normalize_git_remote_url() remote_url = params |> get_in(["git", "remote_url"]) |> GitHandler.normalize_remote_url()
{:noreply, initialize_git(socket, remote_url)} {:noreply, GitHandler.initialize(socket, remote_url)}
end end
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
@@ -270,7 +326,7 @@ 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) socket = TabActions.auto_save_current_post(socket)
workbench = workbench =
Workbench.open_tab( Workbench.open_tab(
@@ -286,11 +342,11 @@ defmodule BDS.Desktop.ShellLive do
socket socket
|> assign(:tab_meta, tab_meta) |> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench) |> refresh_layout(workbench)
|> push_url_state()} |> UrlState.push()}
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) socket = TabActions.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)
@@ -300,7 +356,7 @@ defmodule BDS.Desktop.ShellLive do
socket socket
|> assign(:tab_meta, tab_meta) |> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench) |> refresh_layout(workbench)
|> push_url_state()} |> UrlState.push()}
end end
def handle_event( def handle_event(
@@ -525,34 +581,24 @@ defmodule BDS.Desktop.ShellLive do
def handle_event("restore_workbench_session", %{"session" => session_payload}, socket) def handle_event("restore_workbench_session", %{"session" => session_payload}, socket)
when is_map(session_payload) do when is_map(session_payload) do
{:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))} restored =
session_payload
|> SessionUtil.restore_workbench_session()
|> preserve_active_tab(socket.assigns.workbench)
{:noreply, reload_shell(socket, restored)}
end end
def handle_event("native_menu_action", %{"action" => action}, socket) do # The client signals readiness after mounting its hooks and pushing any
{:noreply, handle_native_menu_action(socket, action)} # stored workbench session. Deep links attach only now, so cold-start
# blogmark links run after rehydration and the editor they open survives.
def handle_event("shell_ready", _params, socket) do
attach_deep_link()
{:noreply, socket}
end end
def handle_event("titlebar_menu_keydown", %{"key" => key}, socket) do def handle_event(event, params, socket) when event in @native_menu_events do
{:noreply, TitlebarMenu.handle_keydown(socket, key, &handle_native_menu_action/2)} NativeMenuEvents.handle(event, params, socket, &handle_native_menu_action/2)
end
def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do
{:noreply, TitlebarMenu.toggle(socket, group)}
end
def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do
{:noreply, TitlebarMenu.hover(socket, group)}
end
def handle_event("close_titlebar_menu", _params, socket) do
{:noreply, TitlebarMenu.close(socket)}
end
def handle_event("titlebar_menu_action", %{"action" => action}, socket) do
{:noreply,
socket
|> TitlebarMenu.close()
|> handle_native_menu_action(action)}
end end
@impl true @impl true
@@ -712,6 +758,28 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, handle_blogmark_deep_link(socket, url)} {:noreply, handle_blogmark_deep_link(socket, url)}
end end
def handle_info({:git_output, ref, stream, chunk}, socket) do
{:noreply, GitRun.append(socket, ref, stream, chunk)}
end
def handle_info({:git_done, ref, status}, socket) do
socket = GitRun.done(socket, ref, status)
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
end
def handle_info({:shell_alert, title, message}, socket) do
overlay = %{kind: :alert, title: title, message: message, tone: "error"}
{:noreply, assign(socket, :shell_overlay, overlay)}
end
# The UI language is a server-side setting shared by all clients; when any
# client (or script) changes it, every shell re-renders in the new locale.
def handle_info({:settings_changed, "ui.language"}, socket) do
{:noreply, set_page_language(socket, ShellData.ui_language())}
end
def handle_info({:settings_changed, _key}, socket), do: {:noreply, socket}
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
@@ -722,241 +790,27 @@ defmodule BDS.Desktop.ShellLive do
index(assigns) index(assigns)
end end
defp refresh_layout(socket, workbench) do # Dark-themed toast classes for LiveToast (its default is bg-white/text-black).
git_badge_count = socket.assigns[:git_badge_count] || 0 # Mirrors the library's structural classes but uses the app's dark palette.
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count) @doc false
@spec toast_class_fn(map()) :: [String.t() | false]
task_status = def toast_class_fn(assigns) do
socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil} [
"group/toast z-100 pointer-events-auto relative w-full items-center justify-between origin-center overflow-hidden rounded-lg p-4 shadow-lg border col-start-1 col-end-1 row-start-1 row-end-2",
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot() "bg-[#2d2d2d] text-[#f0f0f0] border-[#3c3c3c]",
page_language = socket.assigns[:page_language] || ShellData.ui_language() "[@media(scripting:enabled)]:opacity-0 [@media(scripting:enabled){[data-phx-main]_&}]:opacity-100",
offline_mode = Map.get(socket.assigns, :offline_mode, true) if(assigns[:rest][:hidden] == true, do: "hidden", else: "flex"),
sidebar_data = socket.assigns[:sidebar_data] || %{} assigns[:kind] == :error && "!bg-[#3a2020] !text-red-300 !border-[#5a2a2a]"
current_tab = current_tab(workbench) ]
prev_tab = socket.assigns[:current_tab]
prev_panel_tab =
case socket.assigns[:workbench] do
%Workbench{panel: %{active_tab: tab}} -> tab
_ -> nil
end end
socket = defp refresh_layout(socket, workbench), do: SocketState.refresh_layout(socket, workbench)
socket
|> assign(:workbench, workbench)
|> assign(:activity_buttons, activity_buttons)
|> assign(
:sidebar_header,
active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)
)
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|> assign(:current_tab, current_tab)
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|> assign(
:status,
ShellData.status_bar(workbench, task_status, dashboard,
ui_language: page_language,
offline_mode: offline_mode
)
)
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do defp refresh_sidebar(socket, workbench), do: SocketState.refresh_sidebar(socket, workbench)
refresh_panel_data(socket)
else
socket
end
end
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do defp refresh_content(socket, workbench), do: ContentState.refresh_content(socket, workbench)
current_tab != prev_tab or panel_tab != prev_panel_tab
end
defp refresh_panel_data(socket) do defp reload_shell(socket, workbench), do: ContentState.reload_shell(socket, workbench)
panel_tab = socket.assigns.workbench.panel.active_tab
socket
|> assign(
:panel_post_links,
if(panel_tab == :post_links,
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
)
)
|> assign(
:panel_git_entries,
if(panel_tab == :git_log,
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
else: socket.assigns[:panel_git_entries] || []
)
)
end
defp push_url_state(socket) do
workbench = socket.assigns.workbench
params =
%{}
|> put_url_view(workbench.active_view)
|> put_url_tab(workbench.active_tab)
query = URI.encode_query(params)
path = if query == "", do: "/", else: "/?" <> query
push_event(socket, "url-state", %{path: path})
end
defp put_url_view(params, :posts), do: params
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
defp put_url_tab(params, nil), do: params
defp put_url_tab(params, {type, id}),
do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
defp apply_url_params(socket, params) when is_map(params) and map_size(params) > 0 do
workbench = socket.assigns.workbench
workbench = apply_url_view(workbench, Map.get(params, "view"))
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
if workbench == socket.assigns.workbench do
socket
else
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> refresh_sidebar(workbench)
end
end
defp apply_url_params(socket, _params), do: socket
defp apply_url_view(workbench, nil), do: workbench
defp apply_url_view(workbench, view_str) do
view = BoundedAtoms.sidebar_view(view_str, nil)
if view && view != workbench.active_view do
Workbench.click_activity(workbench, view)
else
workbench
end
end
defp apply_url_tab(workbench, nil), do: workbench
defp apply_url_tab(workbench, tab_str) do
case String.split(tab_str, ":", parts: 2) do
[type_str, id] ->
type = BoundedAtoms.editor_route(type_str, nil)
if type && workbench.active_tab != {type, id} do
Workbench.open_tab(workbench, type, id, :preview)
else
workbench
end
_ ->
workbench
end
end
defp refresh_sidebar(socket, workbench) do
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
project_id,
active_view_id,
ShellSidebarState.current_filters(socket, active_view_id)
) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
end
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
socket
|> assign(:sidebar_data, sidebar_data)
|> refresh_layout(workbench)
end
defp refresh_content(socket, workbench) do
projects =
case ShellData.project_snapshot() do
{:ok, data} -> data
{:error, :not_ready} -> ShellData.default_project_snapshot()
end
dashboard =
case ShellData.dashboard(projects.active_project_id) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
end
git_badge_count =
case ShellData.git_badge_count(projects.active_project_id) do
{:ok, count} -> count
{:error, :not_ready} -> 0
end
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
projects.active_project_id,
active_view_id,
ShellSidebarState.current_filters(socket, active_view_id)
) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
end
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
socket
|> assign(:projects, projects)
|> assign(:current_project, ShellData.current_project(projects))
|> assign(:dashboard, dashboard)
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|> assign(
:dashboard_tag_cloud_items,
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
)
|> assign(:git_badge_count, git_badge_count)
|> assign(:sidebar_data, sidebar_data)
|> refresh_layout(workbench)
end
defp reload_shell(socket, workbench) do
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
raw_task_status = BDS.Tasks.status_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode =
if connected?(socket) do
Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
else
Map.get(socket.assigns, :offline_mode, true)
end
task_status = localize_task_status(raw_task_status, page_language)
socket
|> assign(:tab_meta, tab_meta)
|> assign(:task_status, task_status)
|> assign(:offline_mode, offline_mode)
|> assign(:assistant_cards, ShellData.assistant_cards())
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> refresh_content(workbench)
end
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts) defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)
@@ -971,12 +825,6 @@ defmodule BDS.Desktop.ShellLive do
defp activity_label("Source Control"), do: dgettext("ui", "Git") defp activity_label("Source Control"), do: dgettext("ui", "Git")
defp activity_label(label), do: label defp activity_label(label), do: label
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
Enum.find_value(activity_buttons, Map.get(sidebar_data, :title, ""), fn button ->
if button.id == active_view, do: activity_label(button.label), else: nil
end)
end
defp sidebar_header_label(label), do: label defp sidebar_header_label(label), do: label
defp timeline_height(entry, entries) do defp timeline_height(entry, entries) do
@@ -988,12 +836,6 @@ defmodule BDS.Desktop.ShellLive do
max(4, (entry.count || 0) / max_count * 100) max(4, (entry.count || 0) / max_count * 100)
end end
defp current_tab(%{active_tab: nil}), do: nil
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
Enum.find(tabs, &(&1.type == type and &1.id == id))
end
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())
@@ -1001,11 +843,67 @@ defmodule BDS.Desktop.ShellLive do
# create a draft post, open it in the editor, and surface transform toasts. # create a draft post, open it in the editor, and surface transform toasts.
defp handle_blogmark_deep_link(socket, url) do defp handle_blogmark_deep_link(socket, url) do
title = dgettext("ui", "Blogmark") title = dgettext("ui", "Blogmark")
link_project_id = Blogmark.deep_link_project_id(url)
active = current_project_id(socket)
case current_project_id(socket) do cond do
project_id when is_binary(project_id) -> # A specific project was requested but does not exist in this install
case Blogmark.receive_deep_link(project_id, url) do # (e.g. a bookmarklet copied from another instance). Never guess another
# project — tell the user, import nothing.
is_binary(link_project_id) and Projects.get_project(link_project_id) == nil ->
append_output_entry(
socket,
title,
dgettext("ui", "The project this blogmark targets does not exist here."),
url,
"error"
)
# A specific, existing project that isn't active: switch to it first so
# the new post is shown in context, then import there.
is_binary(link_project_id) and link_project_id != active ->
case Projects.set_active_project(link_project_id) do
{:ok, _project} ->
socket
|> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{})
|> reload_shell(Workbench.new())
|> UrlState.push()
|> import_blogmark(link_project_id, url, title)
{:error, reason} ->
append_output_entry(socket, title, inspect(reason), url, "error")
end
# The link names the already-active project, or no project_id was given
# and a project is active: import into the active project.
is_binary(active) ->
import_blogmark(socket, active, url, title)
# No project_id and nothing active.
true ->
append_output_entry(
socket,
title,
dgettext("ui", "Open a project before importing a blogmark."),
url,
"warning"
)
end
end
defp import_blogmark(socket, project_id, url, title) do
# Transform scripts run in the sandbox process; collect anything they
# print/log so it lands in the Output panel like in the old app.
{:ok, output_sink} = Agent.start_link(fn -> [] end)
sink = fn text -> Agent.update(output_sink, fn lines -> [text | lines] end) end
try do
case Blogmark.receive_deep_link(project_id, url, on_output: sink) do
{:ok, %{post: post, toasts: toasts, errors: errors}} -> {:ok, %{post: post, toasts: toasts, errors: errors}} ->
script_output = Agent.get(output_sink, &Enum.reverse/1)
socket socket
|> reload_shell(socket.assigns.workbench) |> reload_shell(socket.assigns.workbench)
|> open_sidebar_item( |> open_sidebar_item(
@@ -1017,30 +915,44 @@ defmodule BDS.Desktop.ShellLive do
}, },
:pin :pin
) )
|> append_blogmark_script_output(title, script_output)
|> append_blogmark_toasts(title, toasts) |> append_blogmark_toasts(title, toasts)
|> append_blogmark_errors(title, errors) |> append_blogmark_errors(title, errors)
|> open_output_panel_when(script_output != [] or errors != [])
{:error, reason} -> {:error, reason} ->
append_output_entry(socket, title, inspect(reason), url, "error") append_output_entry(socket, title, inspect(reason), url, "error")
end end
after
_ -> Agent.stop(output_sink)
append_output_entry(
socket,
title,
dgettext("ui", "Open a project before importing a blogmark."),
url,
"warning"
)
end end
end end
defp append_blogmark_script_output(socket, title, lines) do
Enum.reduce(lines, socket, fn line, acc ->
append_output_entry(acc, title, line, nil, "info")
end)
end
defp append_blogmark_toasts(socket, title, toasts) do defp append_blogmark_toasts(socket, title, toasts) do
Enum.reduce(toasts, socket, fn message, acc -> Enum.reduce(toasts, socket, fn message, acc ->
append_output_entry(acc, title, message, nil, "info") append_output_entry(acc, title, message, nil, "info")
end) end)
end end
# Old-app parity: transform errors or streamed script output open the
# bottom panel on the Output tab so the user actually sees them.
defp open_output_panel_when(socket, true) do
workbench =
socket.assigns.workbench
|> Workbench.set_panel_visible(true)
|> Workbench.set_panel_tab(:output)
refresh_layout(socket, workbench)
end
defp open_output_panel_when(socket, false), do: socket
defp append_blogmark_errors(socket, title, errors) do defp append_blogmark_errors(socket, title, errors) do
Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc -> Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc ->
append_output_entry(acc, title, inspect(reason), slug, "error") append_output_entry(acc, title, inspect(reason), slug, "error")
@@ -1097,122 +1009,31 @@ defmodule BDS.Desktop.ShellLive do
socket socket
|> assign(:tab_meta, tab_meta) |> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench) |> refresh_layout(workbench)
|> push_url_state() |> UrlState.push()
end end
defp run_git_action(socket, event) do # A tab opened before the client's stored session arrives (e.g. a blogmark
project_id = current_project_id(socket) # deep link that switched projects) must survive the restore, so re-open it
# on the restored workbench with its original transient/pinned intent.
defp preserve_active_tab(restored, %{active_tab: nil}), do: restored
{label, result} = defp preserve_active_tab(restored, %{active_tab: {type, id}} = previous) do
case event do intent =
"git_fetch" -> {dgettext("ui", "Fetch"), git_call(project_id, &BDS.Git.fetch/1)} case Enum.find(previous.tabs, &(&1.type == type and &1.id == id)) do
"git_pull" -> {dgettext("ui", "Pull"), git_call(project_id, &BDS.Git.pull/1)} %{is_transient: true} -> :preview
"git_push" -> {dgettext("ui", "Push"), git_call(project_id, &BDS.Git.push/1)} _ -> :pin
"git_prune_lfs" -> {dgettext("ui", "Prune LFS"), prune_lfs(project_id)}
end end
socket Workbench.open_tab(restored, type, id, intent)
|> 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 end
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id] defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
defp normalize_git_remote_url(value) do # Register this shell with the deep-link router and drain any links queued
case value |> to_string() |> String.trim() do # before connect (cold start). The router is absent in headless/test runs.
"" -> nil defp attach_deep_link do
url -> url if Process.whereis(BDS.Desktop.DeepLink) do
BDS.Desktop.DeepLink.attach(self())
end end
end end
@@ -1237,6 +1058,9 @@ defmodule BDS.Desktop.ShellLive do
socket socket
else else
UILocale.put(normalized) UILocale.put(normalized)
# Persist server-side so every client renders the same language; the
# resulting settings_changed broadcast is a no-op for this socket.
_ = BDS.Settings.put_global_setting("ui.language", normalized)
socket socket
|> assign(:page_language, normalized) |> assign(:page_language, normalized)
@@ -1258,7 +1082,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:sidebar_filters_by_view, %{}) |> assign(:sidebar_filters_by_view, %{})
|> append_output_entry(title, message_fun.(project)) |> append_output_entry(title, message_fun.(project))
|> reload_shell(Workbench.new()) |> reload_shell(Workbench.new())
|> push_url_state() |> UrlState.push()
{:error, reason} -> {:error, reason} ->
socket socket
@@ -1268,11 +1092,8 @@ defmodule BDS.Desktop.ShellLive do
end end
end end
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info") do defp append_output_entry(socket, title, message, details \\ nil, level \\ "info"),
entry = %{title: title, message: message, details: details, level: level} do: SocketState.append_output_entry(socket, title, message, details, level)
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
assign(socket, :output_entries, entries)
end
defp handle_native_menu_action(socket, action) do defp handle_native_menu_action(socket, action) do
case BoundedAtoms.menu_action(action) do case BoundedAtoms.menu_action(action) do
@@ -1293,7 +1114,7 @@ defmodule BDS.Desktop.ShellLive do
socket socket
|> assign(:tab_meta, tab_meta) |> assign(:tab_meta, tab_meta)
|> refresh_sidebar(workbench) |> refresh_sidebar(workbench)
|> push_url_state() |> UrlState.push()
MapSet.member?(@socket_menu_actions, action) -> MapSet.member?(@socket_menu_actions, action) ->
handle_socket_menu_action(socket, action) handle_socket_menu_action(socket, action)
@@ -1317,8 +1138,11 @@ defmodule BDS.Desktop.ShellLive do
defp handle_socket_menu_action(socket, :new_post), do: create_sidebar_item(socket, "post") defp handle_socket_menu_action(socket, :new_post), do: create_sidebar_item(socket, "post")
defp handle_socket_menu_action(socket, :import_media), do: create_sidebar_item(socket, "media") defp handle_socket_menu_action(socket, :import_media), do: create_sidebar_item(socket, "media")
defp handle_socket_menu_action(socket, :save), do: save_current_tab(socket)
defp handle_socket_menu_action(socket, :publish_selected), do: publish_current_tab(socket) defp handle_socket_menu_action(socket, :save), do: TabActions.save_current_tab(socket)
defp handle_socket_menu_action(socket, :publish_selected),
do: TabActions.publish_current_tab(socket)
defp handle_socket_menu_action(socket, :quit) do defp handle_socket_menu_action(socket, :quit) do
Shutdown.request_quit() Shutdown.request_quit()
@@ -1335,6 +1159,34 @@ defmodule BDS.Desktop.ShellLive do
socket socket
end end
defp handle_socket_menu_action(socket, :connect_server) do
prompt = dgettext("ui", "Server address (user@host or user@host:port), public-key auth:")
with {:ok, target} <- BDS.Desktop.Dialogs.prompt_text(prompt, "user@host"),
{:ok, url} <- BDS.Desktop.RemoteConnection.connect(target) do
Desktop.Window.show(BDS.Desktop.MainWindow.window_id(), url)
socket
else
:cancel ->
socket
{:error, reason} ->
append_output_entry(
socket,
dgettext("ui", "Connect to Server"),
BDS.Desktop.RemoteConnection.format_reason(reason),
nil,
"error"
)
end
end
defp handle_socket_menu_action(socket, :disconnect_server) do
:ok = BDS.Desktop.RemoteConnection.disconnect()
Desktop.Window.show(BDS.Desktop.MainWindow.window_id(), BDS.Desktop.url())
socket
end
defp handle_socket_menu_action(socket, :about) do defp handle_socket_menu_action(socket, :about) do
append_output_entry( append_output_entry(
socket, socket,
@@ -1347,62 +1199,6 @@ 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
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
send_update(SettingsEditor, id: "settings-editor", action: :save_project)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
send_update(MenuEditor, id: "menu-editor", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
send_update(TagsEditor, id: "tags-editor", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
socket
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
socket
end
defp save_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
defp publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
socket
end
defp publish_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
defp apply_shell_command(socket, action, params \\ %{}), defp apply_shell_command(socket, action, params \\ %{}),
do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks()) do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks())

View File

@@ -5,11 +5,11 @@ defmodule BDS.Desktop.ShellLive.Bridges do
import Phoenix.LiveView, only: [connected?: 1, send_update: 2] import Phoenix.LiveView, only: [connected?: 1, send_update: 2]
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{ChatEditor, PostEditor} alias BDS.Desktop.ShellLive.{ChatEditor, MediaEditor, PostEditor}
alias BDS.Desktop.ShellLive.{CliSync, SessionUtil} alias BDS.Desktop.ShellLive.{CliSync, SessionUtil}
alias BDS.UI.Workbench alias BDS.UI.Workbench
@refreshable_tab_meta_types [:import, :chat] @refreshable_tab_meta_types [:import, :chat, :post, :media]
@spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) :: @spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) ::
{:noreply, Phoenix.LiveView.Socket.t()} {:noreply, Phoenix.LiveView.Socket.t()}
@@ -216,6 +216,26 @@ defmodule BDS.Desktop.ShellLive.Bridges do
{:noreply, socket} {:noreply, socket}
end end
def handle_info({:editor_translation_completed, :post, post_id, language}, socket, _callbacks) do
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :translation_completed,
language: language
)
{:noreply, socket}
end
def handle_info({:editor_translation_completed, :media, media_id, language}, socket, _callbacks) do
send_update(MediaEditor,
id: "media-editor-#{media_id}",
action: :translation_completed,
language: language
)
{:noreply, socket}
end
def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do
send_update(PostEditor, send_update(PostEditor,
id: "post-editor-#{post_id}", id: "post-editor-#{post_id}",
@@ -246,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.Bridges do
socket socket
|> assign(:task_status, task_status) |> assign(:task_status, task_status)
|> assign(:editor_meta, ShellData.editor_meta(task_status)) |> assign(:editor_meta, ShellData.editor_meta(task_status, socket.assigns.page_language))
|> assign( |> assign(
:status, :status,
ShellData.status_bar( ShellData.status_bar(

View File

@@ -90,12 +90,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end end
def handle_event("send_chat_editor_message", _params, socket) do def handle_event("send_chat_editor_message", _params, socket) do
Logger.info("CHAT send_chat_editor_message called, input=#{inspect(socket.assigns.input)}")
{:noreply, do_send_message(socket)} {:noreply, do_send_message(socket)}
end end
def handle_event("abort_chat_editor_message", _params, socket) do def handle_event("abort_chat_editor_message", _params, socket) do
Logger.info("CHAT abort_chat_editor_message called")
{:noreply, do_abort_message(socket)} {:noreply, do_abort_message(socket)}
end end
@@ -581,8 +579,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
def chart_width(_max_value, _value), do: 0 def chart_width(_max_value, _value), do: 0
@spec truthy?(term()) :: boolean() @spec truthy?(term()) :: boolean()
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true def truthy?(value), do: BDS.Values.truthy?(value)
def truthy?(_value), do: false
# ── HEEx components ─────────────────────────────────────────────────────── # ── HEEx components ───────────────────────────────────────────────────────
@@ -996,7 +993,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
try do try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid) Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed chat_editor allow_repo_sandbox error: #{inspect(error)}")
:ok
end end
else else
:ok :ok

View File

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

View File

@@ -62,7 +62,7 @@
<h2><%= dgettext("ui", "API Key Required") %></h2> <h2><%= dgettext("ui", "API Key Required") %></h2>
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p> <p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
<div class="api-key-form"> <div class="api-key-form">
<button class="api-key-submit ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button> <button class="api-key-submit primary ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button>
</div> </div>
</div> </div>
<% else %> <% else %>
@@ -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}>↑</button> <button class="chat-send-button primary 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,82 @@
defmodule BDS.Desktop.ShellLive.ContentState do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.AI
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{SidebarState, SocketState, TabHelpers, TaskLocalization, TitlebarMenu}
def refresh_content(socket, workbench) do
projects =
case ShellData.project_snapshot() do
{:ok, data} -> data
{:error, :not_ready} -> ShellData.default_project_snapshot()
end
dashboard =
case ShellData.dashboard(projects.active_project_id) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
end
git_badge_count =
case ShellData.git_badge_count(projects.active_project_id) do
{:ok, count} -> count
{:error, :not_ready} -> 0
end
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
projects.active_project_id,
active_view_id,
SidebarState.current_filters(socket, active_view_id)
) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
end
sidebar_data = SidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
socket
|> assign(:projects, projects)
|> assign(:current_project, ShellData.current_project(projects))
|> assign(:dashboard, dashboard)
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|> assign(
:dashboard_tag_cloud_items,
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
)
|> assign(:git_badge_count, git_badge_count)
|> assign(:sidebar_data, sidebar_data)
|> SocketState.refresh_layout(workbench)
end
def reload_shell(socket, workbench) do
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
raw_task_status = BDS.Tasks.status_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode =
if Phoenix.LiveView.connected?(socket) do
Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
else
Map.get(socket.assigns, :offline_mode, true)
end
task_status = TaskLocalization.localize_task_status(raw_task_status, page_language)
socket
|> assign(:tab_meta, tab_meta)
|> assign(:task_status, task_status)
|> assign(:offline_mode, offline_mode)
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> refresh_content(workbench)
end
end

View File

@@ -15,14 +15,7 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok @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 def start(paths, project_id, post_id, language, concurrency_limit, parent) do
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
main_language = metadata.main_language || language translate_targets = translate_targets(metadata, 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) {in_flight, remaining} = Enum.split(paths, concurrency_limit)
tasks = tasks =
@@ -48,6 +41,36 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
send(parent, {:add_images_complete, length(paths)}) send(parent, {:add_images_complete, length(paths)})
end end
@doc """
The translation targets for AI-generated media texts: the project main
language plus all blog languages, minus the source `language`.
"""
def translate_targets(metadata, language) do
main_language = metadata.main_language || language
blog_languages = metadata.blog_languages || []
[main_language | blog_languages]
|> Enum.reject(&(&1 == language or is_nil(&1)))
|> Enum.uniq()
end
@doc """
Imports a single file and runs the same best-effort AI enrichment
(title/alt/caption plus translations) as the gallery pipeline, without
linking the media to a post. Used by the CLI `media` command (issue #25).
Returns `{:ok, media, display_title}` or `{:error, reason}`.
"""
def import_and_enrich(path, project_id, language, translate_targets) do
case Media.import_media(%{project_id: project_id, source_path: path}) do
{:ok, media} ->
{:ok, media, enrich_media(media, language, translate_targets)}
{:error, reason} ->
{:error, reason}
end
end
defp drain_tasks( defp drain_tasks(
[], [],
tasks, tasks,
@@ -167,18 +190,14 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
translate_targets, translate_targets,
parent parent
) do ) do
# Linking is mandatory and MUST happen regardless of AI availability
# (airplane mode / no local model), mirroring the drag-drop chain and the
# old app: import -> link -> (optional) AI enrichment. Gating the link
# behind AI is what left galleries empty.
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}), with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
true <- String.starts_with?(media.mime_type || "", "image/"), 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 {:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
translate_media_translations(media.id, translate_targets) title = enrich_media(media, language, translate_targets)
title = result.title || media.original_name
send(parent, {:add_image_processed, title}) send(parent, {:add_image_processed, title})
else else
false -> false ->
@@ -190,6 +209,32 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
end end
end end
# Best-effort AI enrichment: analysis + metadata update + translations. Any
# failure (offline, no model) is logged and never unlinks the image. Returns
# the display title used for progress reporting.
defp enrich_media(media, language, translate_targets) do
case AI.analyze_image(media.id, language: language) do
{:ok, result} ->
case Media.update_media(media.id, %{
title: result.title,
alt: result.alt,
caption: result.caption
}) do
{:ok, _updated} ->
translate_media_translations(media.id, translate_targets)
result.title || media.original_name
{:error, reason} ->
Logger.warning("Media metadata update failed for #{media.id}: #{inspect(reason)}")
media.original_name
end
{:error, reason} ->
Logger.warning("AI image analysis skipped for #{media.id}: #{inspect(reason)}")
media.original_name
end
end
defp translate_media_translations(_media_id, []), do: :ok defp translate_media_translations(_media_id, []), do: :ok
defp translate_media_translations(media_id, [target | rest]) do defp translate_media_translations(media_id, [target | rest]) do

View File

@@ -0,0 +1,126 @@
defmodule BDS.Desktop.ShellLive.GitHandler do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.Desktop.ShellLive.{SocketState, TabHelpers, UrlState}
alias BDS.UI.Workbench
use Gettext, backend: BDS.Gettext
def run_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)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
def commit(socket, "") do
socket
|> SocketState.append_output_entry(
dgettext("ui", "Commit"),
dgettext("ui", "Commit message is required"),
nil,
"error"
)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
def commit(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)
|> SocketState.append_output_entry(dgettext("ui", "Commit"), message, nil, "info")
|> SocketState.refresh_sidebar(workbench)
|> UrlState.push()
{:error, reason} ->
socket
|> SocketState.append_output_entry(dgettext("ui", "Commit"), format_git_error(reason), nil, "error")
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
end
def initialize(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
|> SocketState.append_output_entry(
dgettext("ui", "Initialize Git"),
dgettext("ui", "Repository initialized"),
nil,
"info"
)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
{:error, reason} ->
socket
|> SocketState.append_output_entry(
dgettext("ui", "Initialize Git"),
format_git_error(reason),
nil,
"error"
)
|> SocketState.refresh_sidebar(socket.assigns.workbench)
end
end
def normalize_remote_url(value) do
case value |> to_string() |> String.trim() do
"" -> nil
url -> url
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
SocketState.append_output_entry(socket, label, dgettext("ui", "Done"), nil, "info")
end
defp append_git_result(socket, label, {:error, reason}) do
SocketState.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]
end

View File

@@ -0,0 +1,130 @@
defmodule BDS.Desktop.ShellLive.GitRun do
@moduledoc """
Live modal for streaming network git commands (fetch/pull/push).
Starts a `BDS.Git.Stream`, appends its stdout/stderr chunks as they arrive
(stderr rendered in red), shows a spinner while running and a pass/fail banner
from the exit status. Closing the modal cancels a still-running command, which
kills the OS process.
"""
use Phoenix.Component
use Gettext, backend: BDS.Gettext
alias BDS.Git
@operations %{"git_fetch" => :fetch, "git_pull" => :pull, "git_push" => :push}
@doc "True when the shell event is a streaming network git action."
def network_event?(event), do: Map.has_key?(@operations, event)
@doc "Open the modal and start streaming the operation."
def start(socket, event, project_id) do
operation = Map.fetch!(@operations, event)
run =
case start_stream(project_id, operation) do
{:ok, pid, ref} ->
%{operation: operation, pid: pid, ref: ref, lines: [], status: :running}
{:error, reason} ->
%{
operation: operation,
pid: nil,
ref: nil,
lines: [{:stderr, error_text(reason)}],
status: {:done, 1}
}
end
assign(socket, :git_run, run)
end
@doc "Append a streamed chunk to the modal (newest first internally)."
def append(socket, ref, stream, chunk) do
case socket.assigns[:git_run] do
%{ref: ^ref, lines: lines} = run ->
assign(socket, :git_run, %{run | lines: [{stream, chunk} | lines]})
_other ->
socket
end
end
@doc "Mark the run finished with its exit status."
def done(socket, ref, status) do
case socket.assigns[:git_run] do
%{ref: ^ref} = run ->
assign(socket, :git_run, %{run | status: {:done, status}, pid: nil})
_other ->
socket
end
end
@doc "Close the modal, cancelling (killing) a still-running command."
def close(socket) do
case socket.assigns[:git_run] do
%{status: :running, pid: pid} when is_pid(pid) -> Git.Stream.cancel(pid)
_other -> :ok
end
assign(socket, :git_run, nil)
end
defp start_stream(nil, _operation), do: {:error, :no_project}
defp start_stream("default", _operation), do: {:error, :no_project}
defp start_stream(project_id, operation) when is_binary(project_id),
do: Git.stream(project_id, operation, self())
defp error_text(reason) when reason in [:no_project, :not_found],
do: dgettext("ui", "No active project") <> "\n"
defp error_text(reason), do: inspect(reason) <> "\n"
def git_run_modal(assigns) do
~H"""
<%= if @git_run do %>
<div class="shell-overlay-backdrop git-run-backdrop" data-testid="git-run-backdrop" phx-window-keydown="git_run_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="git_run_close" aria-label={dgettext("ui", "Close")}></button>
<div class="git-run-modal" role="dialog" aria-modal="true">
<div class="git-run-header">
<h2 class="git-run-title">{title(@git_run.operation)}</h2>
<span class={["git-run-status", status_class(@git_run.status)]} data-testid="git-run-status">
{status_label(@git_run.status)}
</span>
</div>
<pre class="git-run-output" data-testid="git-run-output"><%= for {stream, text} <- Enum.reverse(@git_run.lines) do %><span class={line_class(stream)}>{text}</span><% end %></pre>
<div class="git-run-footer">
<button class="git-action-button" type="button" phx-click="git_run_close" data-testid="git-run-close">
{close_label(@git_run.status)}
</button>
</div>
</div>
</div>
<% end %>
"""
end
defp title(:fetch), do: dgettext("ui", "git fetch")
defp title(:pull), do: dgettext("ui", "git pull")
defp title(:push), do: dgettext("ui", "git push")
defp line_class(:stderr), do: "git-run-line git-run-stderr"
defp line_class(:stdout), do: "git-run-line git-run-stdout"
defp status_class(:running), do: "running"
defp status_class({:done, 0}), do: "ok"
defp status_class({:done, _status}), do: "fail"
defp status_label(:running), do: dgettext("ui", "Running…")
defp status_label({:done, 0}), do: dgettext("ui", "Done")
defp status_label({:done, status}),
do: dgettext("ui", "Failed (exit %{status})", status: status)
defp close_label(:running), do: dgettext("ui", "Cancel")
defp close_label({:done, _status}), do: dgettext("ui", "Close")
end

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
alias BDS.{AI, ImportAnalysis, ImportDefinitions, ImportExecution} alias BDS.{AI, ImportAnalysis, ImportDefinitions, ImportExecution}
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData} alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
alias BDS.Desktop.ShellLive.Notify alias BDS.Desktop.ShellLive.Notify
require Logger
alias BDS.Desktop.ShellLive.ImportEditor.{ alias BDS.Desktop.ShellLive.ImportEditor.{
AnalysisState, AnalysisState,
@@ -839,7 +840,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
<div class={["import-file-path", if(blank?(@import_editor.uploads_folder_path), do: "placeholder")]}> <div class={["import-file-path", if(blank?(@import_editor.uploads_folder_path), do: "placeholder")]}>
<%= @import_editor.uploads_folder_path || dgettext("ui", "No folder selected") %> <%= @import_editor.uploads_folder_path || dgettext("ui", "No folder selected") %>
</div> </div>
<button type="button" phx-click="select_import_uploads_folder" phx-target={@myself}><%= dgettext("ui", "Open") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="select_import_uploads_folder" phx-target={@myself}><%= dgettext("ui", "Open") %></button>
</div> </div>
<div class="import-file-row"> <div class="import-file-row">
@@ -847,7 +848,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
<div class={["import-file-path", if(blank?(@import_editor.wxr_file_path), do: "placeholder")]}> <div class={["import-file-path", if(blank?(@import_editor.wxr_file_path), do: "placeholder")]}>
<%= @import_editor.wxr_file_path || dgettext("ui", "Select a file to analyze") %> <%= @import_editor.wxr_file_path || dgettext("ui", "Select a file to analyze") %>
</div> </div>
<button class="import-analyze-btn" type="button" phx-click="select_import_wxr_file" phx-target={@myself}><%= dgettext("ui", "Select & Analyze") %></button> <button class="import-analyze-btn primary ui-button ui-button-primary" type="button" phx-click="select_import_wxr_file" phx-target={@myself}><%= dgettext("ui", "Select & Analyze") %></button>
</div> </div>
</div> </div>
@@ -943,7 +944,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
<%= if @counts.pages > 0 do %><span class="import-count-tag"><%= @counts.pages %> <%= dgettext("ui", "pages") %></span><% end %> <%= if @counts.pages > 0 do %><span class="import-count-tag"><%= @counts.pages %> <%= dgettext("ui", "pages") %></span><% end %>
</div> </div>
<button class="import-execute-btn" type="button" phx-click="execute_import_editor" phx-target={@myself} disabled={@counts.total == 0}> <button class="import-execute-btn primary ui-button ui-button-primary" type="button" phx-click="execute_import_editor" phx-target={@myself} disabled={@counts.total == 0}>
<%= if @counts.total == 0 do %> <%= if @counts.total == 0 do %>
<%= dgettext("ui", "Nothing to Import") %> <%= dgettext("ui", "Nothing to Import") %>
<% else %> <% else %>
@@ -999,7 +1000,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
<%= if @sections.taxonomy do %> <%= if @sections.taxonomy do %>
<div class="taxonomy-analyze-row"> <div class="taxonomy-analyze-row">
<div class="taxonomy-analyze-dropdown"> <div class="taxonomy-analyze-dropdown">
<button class="taxonomy-analyze-btn" type="button" phx-click="toggle_import_ai_model_selector" phx-target={@myself}><%= dgettext("ui", "Analyze with...") %></button> <button class="taxonomy-analyze-btn secondary ui-button ui-button-secondary" type="button" phx-click="toggle_import_ai_model_selector" phx-target={@myself}><%= dgettext("ui", "Analyze with...") %></button>
<%= if @import_editor.model_selector_open? do %> <%= if @import_editor.model_selector_open? do %>
<div class="taxonomy-model-dropdown"> <div class="taxonomy-model-dropdown">
<%= for model <- @import_editor.available_models do %> <%= for model <- @import_editor.available_models do %>
@@ -1011,7 +1012,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
<% end %> <% end %>
</div> </div>
<button class="taxonomy-analyze-btn" type="button" phx-click="analyze_import_taxonomy_ai" phx-target={@myself} disabled={Enum.empty?(@import_editor.available_models) and not @import_editor.offline?}> <button class="taxonomy-analyze-btn secondary ui-button ui-button-secondary" type="button" phx-click="analyze_import_taxonomy_ai" phx-target={@myself} disabled={Enum.empty?(@import_editor.available_models) and not @import_editor.offline?}>
<%= @import_editor.selected_model_label %> <%= @import_editor.selected_model_label %>
</button> </button>
@@ -1361,7 +1362,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
list={"taxonomy-suggestions-#{@type}"} list={"taxonomy-suggestions-#{@type}"}
autocomplete="off" autocomplete="off"
/> />
<button class="taxonomy-edit-btn" type="submit" title={dgettext("ui", "Map to...")}>✓</button> <button class="taxonomy-edit-btn secondary" type="submit" title={dgettext("ui", "Map to...")}>✓</button>
<button class="taxonomy-edit-btn ghost" type="button" phx-click="cancel_import_taxonomy_edit" phx-target={@myself} title={dgettext("ui", "Cancel")}>×</button> <button class="taxonomy-edit-btn ghost" type="button" phx-click="cancel_import_taxonomy_edit" phx-target={@myself} title={dgettext("ui", "Cancel")}>×</button>
<%= if present?(item.mapped_to) do %> <%= if present?(item.mapped_to) do %>
<button class="taxonomy-clear-btn" type="button" phx-click="clear_import_taxonomy_mapping" phx-target={@myself} phx-value-type={@type} phx-value-name={item.name} title={dgettext("ui", "Clear mapping")}>×</button> <button class="taxonomy-clear-btn" type="button" phx-click="clear_import_taxonomy_mapping" phx-target={@myself} phx-value-type={@type} phx-value-name={item.name} title={dgettext("ui", "Clear mapping")}>×</button>
@@ -1417,7 +1418,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
try do try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid) Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed import_editor allow_repo_sandbox error: #{inspect(error)}")
:ok
end end
else else
:ok :ok
@@ -1449,6 +1452,6 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
Map.get(item, :resolution) in ["overwrite", "merge"] Map.get(item, :resolution) in ["overwrite", "merge"]
end end
defp present?(value), do: value not in [nil, ""] defp present?(value), do: BDS.Values.present?(value)
defp blank?(value), do: value in [nil, ""] defp blank?(value), do: BDS.Values.blank?(value)
end end

View File

@@ -3,6 +3,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata} alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
alias BDS.Desktop.{FilePicker, FolderPicker} alias BDS.Desktop.{FilePicker, FolderPicker}
require Logger
use Gettext, backend: BDS.Gettext use Gettext, backend: BDS.Gettext
@spec change_definition(term(), term(), term()) :: term() @spec change_definition(term(), term(), term()) :: term()
@@ -280,7 +281,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
try do try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid) Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
rescue rescue
_error -> :ok error ->
Logger.debug(
"swallowed import_editor analysis_state allow_repo_sandbox error: #{inspect(error)}"
)
:ok
end end
else else
:ok :ok
@@ -304,5 +310,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@spec translate_phase(term()) :: term() @spec translate_phase(term()) :: term()
def translate_phase(other), do: other def translate_phase(other), do: other
defp present?(value), do: value not in [nil, ""] defp present?(value), do: BDS.Values.present?(value)
end end

View File

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

View File

@@ -457,7 +457,7 @@
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto"> <div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
<%= for tab <- @panel_tabs do %> <%= for tab <- @panel_tabs do %>
<button <button
class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex h-9 items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]} class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]}
type="button" type="button"
phx-click="select_panel_tab" phx-click="select_panel_tab"
phx-value-tab={tab} phx-value-tab={tab}
@@ -531,7 +531,7 @@
><%= @assistant_prompt %></textarea> ><%= @assistant_prompt %></textarea>
<button <button
class="assistant-sidebar-start-button ui-button ui-button-primary" class="assistant-sidebar-start-button primary ui-button ui-button-primary"
data-testid="assistant-start-button" data-testid="assistant-start-button"
type="submit" type="submit"
disabled={String.trim(@assistant_prompt || "") == ""} disabled={String.trim(@assistant_prompt || "") == ""}
@@ -540,16 +540,7 @@
</button> </button>
</form> </form>
<%= if Enum.empty?(@assistant_messages) do %> <%= unless Enum.empty?(@assistant_messages) do %>
<div class="assistant-sidebar-welcome min-h-0 flex-1 overflow-auto">
<%= for card <- @assistant_cards do %>
<section class="assistant-card flex flex-col gap-1">
<strong><%= card.label %></strong>
<span><%= card.text %></span>
</section>
<% end %>
</div>
<% else %>
<div class="assistant-sidebar-transcript min-h-0 flex-1 overflow-auto"> <div class="assistant-sidebar-transcript min-h-0 flex-1 overflow-auto">
<%= for message <- @assistant_messages do %> <%= for message <- @assistant_messages do %>
<article <article
@@ -687,4 +678,6 @@
</footer> </footer>
<ShellOverlayComponents.shell_overlay shell_overlay={@shell_overlay} /> <ShellOverlayComponents.shell_overlay shell_overlay={@shell_overlay} />
<LiveToast.toast_group flash={@flash} connected={assigns[:socket] != nil} toasts_sync={assigns[:toasts_sync]} corner={:bottom_right} toast_class_fn={&toast_class_fn/1} />
<GitRun.git_run_modal git_run={@git_run} />
</div> </div>

View File

@@ -7,7 +7,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
alias BDS.Desktop.{FilePicker} alias BDS.Desktop.{FilePicker}
alias BDS.Desktop.ShellLive.Notify alias BDS.Desktop.ShellLive.Notify
alias BDS.{AI, I18n, Media} alias BDS.{AI, I18n, Media, Tasks}
alias BDS.Media.Media, as: MediaRecord alias BDS.Media.Media, as: MediaRecord
alias BDS.Media.Translation alias BDS.Media.Translation
alias BDS.Posts.Post alias BDS.Posts.Post
@@ -57,6 +57,17 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
{:ok, socket} {:ok, socket}
end end
def update(%{action: :translation_completed} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action, :language]))
|> assign(:quick_actions_open?, false)
|> assign(:editing_translation, nil)
|> build_data()
{:ok, socket}
end
def update(assigns, socket) do def update(assigns, socket) do
socket = socket =
socket socket
@@ -552,25 +563,34 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
media = socket.assigns.media media = socket.assigns.media
normalized_language = normalize_language(language) normalized_language = normalize_language(language)
source_language = normalize_language(media.language) source_language = normalize_language(media.language)
parent = self()
{:ok, _task} =
Tasks.submit_task(
"Translate Media to #{normalized_language}",
fn report ->
report.(0.1, "Translating media to #{normalized_language}")
with {:ok, translation} <-
AI.translate_media(media.id, normalized_language,
source_language: source_language
),
{:ok, _saved_translation} <-
Media.upsert_media_translation(media.id, normalized_language, translation) do
report.(1.0, "Media translation complete")
send(parent, {:editor_translation_completed, :media, media.id, normalized_language})
%{media_id: media.id, language: normalized_language}
else
{:error, reason} -> {:error, reason}
end
end,
%{group_id: media.project_id, group_name: "AI"}
)
case AI.translate_media(media.id, normalized_language, source_language: source_language) do
{:ok, translation} ->
case Media.upsert_media_translation(media.id, normalized_language, translation) do
{:ok, _saved_translation} ->
socket socket
|> assign(:quick_actions_open?, false) |> assign(:quick_actions_open?, false)
|> assign(:editing_translation, nil) |> assign(:editing_translation, nil)
|> build_data() |> build_data()
{:error, reason} ->
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|> build_data()
end
{:error, reason} ->
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|> build_data()
end
end end
end end
@@ -625,7 +645,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
defp preview_url(%MediaRecord{} = media) do defp preview_url(%MediaRecord{} = media) do
if image?(media), if image?(media),
do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}", do: "/media-file/#{media.id}?t=#{media.updated_at}",
else: nil else: nil
end end

View File

@@ -85,7 +85,7 @@
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="replace_media_editor_file" phx-target={@myself}> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="replace_media_editor_file" phx-target={@myself}>
<%= dgettext("ui", "Replace File") %> <%= dgettext("ui", "Replace File") %>
</button> </button>
<button class="ui-button ui-button-primary" data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-target={@myself}> <button class="primary ui-button ui-button-primary" data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-target={@myself}>
<%= dgettext("ui", "Save") %> <%= dgettext("ui", "Save") %>
</button> </button>
<button <button
@@ -211,7 +211,7 @@
<div class="editor-field linked-posts-section flex flex-col gap-2"> <div class="editor-field linked-posts-section flex flex-col gap-2">
<label> <label>
<%= dgettext("ui", "Linked Posts") %> <%= dgettext("ui", "Linked Posts") %>
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-target={@myself}> <button class="add-link-btn secondary compact ui-button ui-button-secondary ui-button-compact" type="button" phx-click="toggle_media_post_picker" phx-target={@myself}>
<%= dgettext("ui", "Link to Post") %> <%= dgettext("ui", "Link to Post") %>
</button> </button>
</label> </label>
@@ -296,7 +296,7 @@
</form> </form>
<div class="translation-modal-footer flex items-center justify-end gap-2"> <div class="translation-modal-footer flex items-center justify-end gap-2">
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="close_media_translation_editor" phx-target={@myself}><%= dgettext("ui", "Cancel") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="close_media_translation_editor" phx-target={@myself}><%= dgettext("ui", "Cancel") %></button>
<button class="ui-button ui-button-primary" type="button" phx-click="save_media_translation" phx-target={@myself}><%= dgettext("ui", "Save") %></button> <button class="primary ui-button ui-button-primary" type="button" phx-click="save_media_translation" phx-target={@myself}><%= dgettext("ui", "Save") %></button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,7 +6,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
import Phoenix.HTML, only: [raw: 1] import Phoenix.HTML, only: [raw: 1]
import Ecto.Query import Ecto.Query
alias BDS.{Embeddings, Generation, Git, HelpDocs, Posts, Repo} alias BDS.{Embeddings, Git, HelpDocs, Posts, Repo}
alias BDS.Desktop.ShellLive.Notify alias BDS.Desktop.ShellLive.Notify
alias BDS.MapUtils alias BDS.MapUtils
alias BDS.Settings.Setting alias BDS.Settings.Setting
@@ -77,7 +77,6 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
def handle_event("apply_site_validation", _params, socket) do def handle_event("apply_site_validation", _params, socket) do
meta = meta(socket.assigns) meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{}) payload = Map.get(meta, :payload, %{})
project_id = Map.get(meta, :project_id, socket.assigns.project_id)
report = %{ report = %{
sitemap_path: Map.get(payload, :sitemap_path), sitemap_path: Map.get(payload, :sitemap_path),
@@ -89,20 +88,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
existing_html_url_count: Map.get(payload, :existing_html_url_count, 0) existing_html_url_count: Map.get(payload, :existing_html_url_count, 0)
} }
case Generation.apply_validation(project_id, report) do notify_command("apply_site_validation", %{report: report})
{:ok, result} ->
notify_output(
dgettext("ui", "Site Validation"),
dgettext("ui", "Validation changes applied"),
inspect(result)
)
notify_command("validate_site")
{:noreply, socket}
end
rescue
error ->
notify_output(dgettext("ui", "Site Validation"), inspect(error), nil, "error")
{:noreply, socket} {:noreply, socket}
end end

View File

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

View File

@@ -20,6 +20,23 @@ defmodule BDS.Desktop.ShellLive.Notify do
:ok :ok
end end
@doc """
Sends an output entry to a specific LiveView process. Needed when the
caller runs in a different process than the shell — e.g. script output
callbacks invoked inside the sandboxed script process.
"""
@spec output_to(pid(), String.t(), String.t(), String.t() | nil, String.t()) :: :ok
def output_to(pid, title, message, detail, level) when is_pid(pid) do
send(pid, {:editor_output, title, message, detail, level})
:ok
end
@spec alert(String.t(), String.t()) :: :ok
def alert(title, message) do
send(self(), {:shell_alert, title, message})
:ok
end
@spec tab_meta(atom(), term(), String.t(), String.t()) :: :ok @spec tab_meta(atom(), term(), String.t(), String.t()) :: :ok
def tab_meta(type, id, title, subtitle) do def tab_meta(type, id, title, subtitle) do
send(self(), {:editor_tab_meta, type, id, %{title: title, subtitle: subtitle || ""}}) send(self(), {:editor_tab_meta, type, id, %{title: title, subtitle: subtitle || ""}})

View File

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

View File

@@ -39,8 +39,8 @@
</div> </div>
</div> </div>
<div class="ai-suggestions-modal-footer"> <div class="ai-suggestions-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button> <button class="button-cancel secondary ui-button ui-button-secondary" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Apply Selected") %></button> <button class="button-apply primary ui-button ui-button-primary" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Apply Selected") %></button>
</div> </div>
</div> </div>
</div> </div>
@@ -82,7 +82,7 @@
<span class="insert-modal-label"><%= dgettext("ui", "Display Text") %></span> <span class="insert-modal-label"><%= dgettext("ui", "Display Text") %></span>
<input class="insert-modal-input" type="text" name="overlay[text]" value={@shell_overlay.external_text} /> <input class="insert-modal-input" type="text" name="overlay[text]" value={@shell_overlay.external_text} />
</label> </label>
<button class="insert-modal-submit" type="button" phx-click="overlay_insert_external"><%= dgettext("ui", "Insert") %></button> <button class="insert-modal-submit primary ui-button ui-button-primary" type="button" phx-click="overlay_insert_external"><%= dgettext("ui", "Insert") %></button>
</form> </form>
<% end %> <% end %>
</div> </div>
@@ -162,8 +162,8 @@
<% end %> <% end %>
</div> </div>
<div class="confirm-delete-modal-footer"> <div class="confirm-delete-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button> <button class="button-cancel secondary ui-button ui-button-secondary" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
<button class="button-delete" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Delete") %></button> <button class="button-delete danger ui-button ui-button-danger" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Delete") %></button>
</div> </div>
</div> </div>
</div> </div>
@@ -180,8 +180,25 @@
<div class="confirm-delete-message"><%= @shell_overlay.message %></div> <div class="confirm-delete-message"><%= @shell_overlay.message %></div>
</div> </div>
<div class="confirm-delete-modal-footer"> <div class="confirm-delete-modal-footer">
<button class="button-cancel" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button> <button class="button-cancel secondary ui-button ui-button-secondary" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Confirm") %></button> <button class="button-apply primary ui-button ui-button-primary" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Confirm") %></button>
</div>
</div>
</div>
<% :alert -> %>
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={dgettext("ui", "Close")}></button>
<div class={["confirm-delete-modal", "alert-modal", "alert-modal-#{@shell_overlay.tone}"]} role="alertdialog" aria-modal="true">
<div class="confirm-delete-modal-header">
<h2><%= @shell_overlay.title %></h2>
<button class="confirm-delete-modal-close" type="button" phx-click="close_overlay">×</button>
</div>
<div class="confirm-delete-modal-body">
<div class="confirm-delete-message"><%= @shell_overlay.message %></div>
</div>
<div class="confirm-delete-modal-footer">
<button class="button-apply primary ui-button ui-button-primary" type="button" phx-click="close_overlay"><%= dgettext("ui", "OK") %></button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -155,7 +155,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
ShellOverlayComponents.markdown_link(result.title, result.canonical_url)} ShellOverlayComponents.markdown_link(result.title, result.canonical_url)}
) )
socket assign(socket, :shell_overlay, nil)
end end
{%{kind: :insert_media}, %{type: :post, id: post_id}} -> {%{kind: :insert_media}, %{type: :post, id: post_id}} ->
@@ -172,7 +172,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
end end
Notify.parent({:post_editor_insert_content, post_id, syntax}) Notify.parent({:post_editor_insert_content, post_id, syntax})
socket assign(socket, :shell_overlay, nil)
end end
_other -> _other ->
@@ -197,9 +197,10 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
if details do if details do
Notify.parent({:post_editor_insert_content, post_id, details}) Notify.parent({:post_editor_insert_content, post_id, details})
end assign(socket, :shell_overlay, nil)
else
socket socket
end
_other -> _other ->
socket socket
@@ -215,7 +216,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
case {socket.assigns[:shell_overlay], current_tab} do case {socket.assigns[:shell_overlay], current_tab} do
{%{kind: :language_picker}, %{type: :post, id: post_id}} -> {%{kind: :language_picker}, %{type: :post, id: post_id}} ->
Notify.parent({:post_editor_translate, post_id, code}) Notify.parent({:post_editor_translate, post_id, code})
socket assign(socket, :shell_overlay, nil)
{%{kind: :language_picker}, %{type: :media, id: media_id}} -> {%{kind: :language_picker}, %{type: :media, id: media_id}} ->
send_update(MediaEditor, send_update(MediaEditor,
@@ -224,7 +225,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
language: code language: code
) )
socket assign(socket, :shell_overlay, nil)
_other -> _other ->
socket socket

View File

@@ -10,6 +10,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
alias BDS.PostLinks alias BDS.PostLinks
alias BDS.Posts alias BDS.Posts
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.UI.TaskGrouping
use Gettext, backend: BDS.Gettext use Gettext, backend: BDS.Gettext
@doc "Render the active panel tab body." @doc "Render the active panel tab body."
@@ -48,34 +49,122 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
end end
defp render_task_entries(assigns) do defp render_task_entries(assigns) do
entries = TaskGrouping.build_task_entries(Map.get(assigns.task_status, :tasks, []))
collapsed = Map.get(assigns, :collapsed_task_groups) || MapSet.new()
assigns =
assigns
|> assign(:task_entries, entries)
|> assign(:collapsed_task_groups, collapsed)
~H""" ~H"""
<%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %> <%= if Enum.empty?(@task_entries) do %>
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state"> <div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
<strong><%= dgettext("ui", "Tasks") %></strong> <strong><%= dgettext("ui", "Tasks") %></strong>
<span><%= dgettext("ui", "No background tasks running") %></span> <span><%= dgettext("ui", "No background tasks running") %></span>
</div> </div>
<% else %> <% else %>
<div class="task-list flex flex-col gap-2"> <div class="task-list flex flex-col gap-2">
<%= for task <- Map.get(@task_status, :tasks, []) do %> <%= for entry <- @task_entries do %>
<div class="panel-entry ui-panel-entry task-entry flex flex-col gap-2"> <%= case entry do %>
<div class="task-entry-header flex items-center justify-between gap-2"> <% {:single, task} -> %>
<strong><%= task.name %></strong> {render_task_row(assigns, task, false)}
<span class={"task-status task-status-#{task.status}"}><%= Map.get(task, :status_label, task.status |> to_string() |> String.capitalize()) %></span> <% {:group, group_id, group_name, tasks} -> %>
</div> {render_task_group(assigns, group_id, group_name, tasks)}
<span><%= task.message || task.group_name || "" %></span>
<%= if is_number(task.progress) do %>
<div class="task-progress-row">
<progress max="1" value={task.progress}></progress>
<span><%= Map.get(task, :progress_label, progress_percent(task.progress)) %></span>
</div>
<% end %> <% end %>
</div>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
""" """
end end
defp render_task_group(assigns, group_id, group_name, tasks) do
summary = TaskGrouping.summarize_task_group(tasks)
expanded = not MapSet.member?(assigns.collapsed_task_groups, group_id)
assigns =
assigns
|> assign(:group_id, group_id)
|> assign(:group_name, group_name)
|> assign(:group_tasks, tasks)
|> assign(:group_expanded, expanded)
|> assign(:group_meta, task_group_meta(summary))
~H"""
<div class="task-group flex flex-col">
<button
class="panel-entry ui-panel-entry task-entry task-group-toggle flex w-full items-center gap-2 text-left"
type="button"
phx-click="toggle_task_group"
phx-value-group={@group_id}
aria-expanded={to_string(@group_expanded)}
>
<span class="task-group-chevron">{if @group_expanded, do: "▾", else: "▸"}</span>
<strong class="task-group-title"><%= @group_name %> (<%= length(@group_tasks) %>)</strong>
<span class="task-group-meta text-muted text-xs"><%= @group_meta %></span>
</button>
<%= if @group_expanded do %>
<%= for task <- @group_tasks do %>
{render_task_row(assigns, task, true)}
<% end %>
<% end %>
</div>
"""
end
defp render_task_row(assigns, task, is_child) do
assigns =
assigns
|> assign(:task, task)
|> assign(:task_child?, is_child)
~H"""
<div class={["panel-entry", "ui-panel-entry", "task-entry", "flex flex-col gap-2", if(@task_child?, do: "ml-4")]}>
<div class="task-entry-header flex items-center justify-between gap-2">
<strong><%= @task.name %></strong>
<span class={"task-status task-status-#{@task.status}"}><%= Map.get(@task, :status_label, @task.status |> to_string() |> String.capitalize()) %></span>
</div>
<span><%= @task.message || @task.group_name || "" %></span>
<%= if is_number(@task.progress) do %>
<div class="task-progress-row">
<progress max="1" value={@task.progress}></progress>
<span><%= Map.get(@task, :progress_label, progress_percent(@task.progress)) %></span>
</div>
<% end %>
<%= if @task.status == :running do %>
<div class="task-entry-actions flex justify-end">
<button
class="ui-button ui-button-secondary inline-flex items-center px-2 py-1 text-xs"
data-testid="cancel-task-button"
type="button"
phx-click="cancel_task"
phx-value-id={@task.id}
>
<%= dgettext("ui", "Cancel") %>
</button>
</div>
<% end %>
</div>
"""
end
# "NN% · X Running · Y Pending" — the breakdown only lists non-zero counts,
# like the old app's summarizeTaskGroup rendering.
defp task_group_meta(summary) do
parts =
[]
|> maybe_append_breakdown(summary.running, dgettext("ui", "Running"))
|> maybe_append_breakdown(summary.pending, dgettext("ui", "Pending"))
suffix = if parts == [], do: "", else: " · " <> Enum.join(parts, " · ")
progress_percent(summary.progress) <> suffix
end
defp maybe_append_breakdown(parts, count, _label) when count <= 0, do: parts
defp maybe_append_breakdown(parts, count, label),
do: parts ++ ["#{count} #{label}"]
defp render_output_entries(assigns) do defp render_output_entries(assigns) do
~H""" ~H"""
<%= if Enum.empty?(@output_entries) do %> <%= if Enum.empty?(@output_entries) do %>
@@ -84,6 +173,16 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
<span><%= dgettext("ui", "No shell output yet") %></span> <span><%= dgettext("ui", "No shell output yet") %></span>
</div> </div>
<% else %> <% else %>
<div class="output-toolbar flex shrink-0 justify-end">
<button
class="ui-button ui-button-secondary inline-flex items-center px-2 py-1 text-xs"
data-testid="copy-output-button"
type="button"
phx-click="copy_output"
>
<%= dgettext("ui", "Copy") %>
</button>
</div>
<div class="output-list flex flex-col gap-2"> <div class="output-list flex flex-col gap-2">
<%= for entry <- @output_entries do %> <%= for entry <- @output_entries do %>
<div class={[ <div class={[
@@ -300,5 +399,5 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
"#{rounded}%" "#{rounded}%"
end end
defp present?(value), do: value not in [nil, ""] defp present?(value), do: BDS.Values.present?(value)
end end

View File

@@ -3,16 +3,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
use Phoenix.LiveComponent use Phoenix.LiveComponent
alias BDS.{AI, Metadata, Posts, Preview} alias BDS.{AI, Embeddings, Metadata, Posts, Preview, Tasks}
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{EditorImageDrop, Notify} alias BDS.Desktop.ShellLive.{EditorImageDrop, Notify}
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata}
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Tags alias BDS.Tags
alias BDS.UI.PostEditor.{Draft, ListValues, Persistence}
import DraftManagement, import Draft,
only: [ only: [
current_draft: 4,
editing_canonical_language?: 3, editing_canonical_language?: 3,
normalize_language: 2, normalize_language: 2,
normalize_mode: 1, normalize_mode: 1,
@@ -49,7 +48,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
use Gettext, backend: BDS.Gettext use Gettext, backend: BDS.Gettext
import PostMetadata, import BDS.UI.PostEditor.Metadata,
only: [ only: [
blank?: 1, blank?: 1,
blank_to_nil: 1, blank_to_nil: 1,
@@ -82,6 +81,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
{:ok, socket} {:ok, socket}
end end
def update(%{action: :auto_save} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action]))
|> do_save(true)
{:ok, socket}
end
def update(%{action: :publish} = assigns, socket) do def update(%{action: :publish} = assigns, socket) do
socket = socket =
socket socket
@@ -109,7 +117,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
id: socket.assigns.post_id, id: socket.assigns.post_id,
content: content content: content
}) })
|> assign(:shell_overlay, nil)
{:ok, socket} {:ok, socket}
end end
@@ -123,6 +130,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
{:ok, socket} {:ok, socket}
end end
def update(%{action: :translation_completed, language: language} = assigns, socket) do
socket =
socket
|> assign(Map.drop(assigns, [:action, :language]))
|> apply_translation_completed(language)
{:ok, socket}
end
def update(%{action: :apply_ai_suggestions, fields: fields} = assigns, socket) do def update(%{action: :apply_ai_suggestions, fields: fields} = assigns, socket) do
socket = socket =
socket socket
@@ -279,6 +295,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
{:noreply, do_detect_language(socket)} {:noreply, do_detect_language(socket)}
end end
def handle_event("focus_post_editor_tags", _params, socket) do
{:noreply, socket |> load_tag_semantic_suggestions() |> build_data()}
end
def handle_event("add_post_editor_tag", %{"tag" => tag}, socket) do def handle_event("add_post_editor_tag", %{"tag" => tag}, socket) do
{:noreply, do_add_list_value(socket, :tags, tag)} {:noreply, do_add_list_value(socket, :tags, tag)}
end end
@@ -295,18 +315,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
{:noreply, do_remove_list_value(socket, :categories, category)} {:noreply, do_remove_list_value(socket, :categories, category)}
end end
def handle_event("insert_content", %{"content" => content}, socket) do
socket =
socket
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{
id: socket.assigns.post_id,
content: content
})
|> assign(:shell_overlay, nil)
{:noreply, socket}
end
def handle_event("close_quick_actions", _params, socket) do def handle_event("close_quick_actions", _params, socket) do
socket = socket =
socket socket
@@ -316,6 +324,19 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
{:noreply, socket} {:noreply, socket}
end end
# Semantic tag suggestions read the embeddings index (no live model call), so
# they work offline; suggest_tags/2 returns [] when similarity is disabled.
defp load_tag_semantic_suggestions(socket) do
case socket.assigns.post do
%Post{} = post ->
{:ok, suggestions} = Embeddings.suggest_tags(post.id, [])
assign(socket, :tag_semantic_suggestions, suggestions)
_other ->
socket
end
end
defp component_current_draft(socket, post, metadata, active_language) do defp component_current_draft(socket, post, metadata, active_language) do
persisted = persisted_form(post, metadata, active_language) persisted = persisted_form(post, metadata, active_language)
Map.get(socket.assigns.drafts, active_language, persisted) Map.get(socket.assigns.drafts, active_language, persisted)
@@ -335,6 +356,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
active_language: canonical, active_language: canonical,
drafts: %{}, drafts: %{},
tag_query: "", tag_query: "",
tag_semantic_suggestions: [],
category_query: "", category_query: "",
quick_actions_open?: false, quick_actions_open?: false,
mode: :markdown, mode: :markdown,
@@ -426,6 +448,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
Tags.list_tags(post.project_id), Tags.list_tags(post.project_id),
socket.assigns.tag_query socket.assigns.tag_query
), ),
tag_semantic_suggestions:
Enum.reject(
socket.assigns.tag_semantic_suggestions || [],
&(&1 in tag_values(form))
),
category_suggestions: category_suggestions:
category_suggestions( category_suggestions(
form, form,
@@ -451,7 +478,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
defp do_save(socket) do defp do_save(socket, keep_draft? \\ false) do
post = socket.assigns.post post = socket.assigns.post
case post do case post do
@@ -463,15 +490,26 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
active_language = socket.assigns.active_language active_language = socket.assigns.active_language
draft = component_current_draft(socket, post, metadata, active_language) draft = component_current_draft(socket, post, metadata, active_language)
case persist(post, draft, active_language, metadata, :save) do action = if keep_draft?, do: :auto_save, else: :save
case persist(post, draft, active_language, metadata, action) do
{:ok, record} -> {:ok, record} ->
refreshed_post = Posts.get_post!(post.id) refreshed_post = Posts.get_post!(post.id)
_refreshed_form = persisted_form(refreshed_post, metadata, active_language) _refreshed_form = persisted_form(refreshed_post, metadata, active_language)
# Autosave keeps the working draft so the live editor stays the source
# of truth; only an explicit save drops it and reloads canonical body.
drafts =
if keep_draft? do
socket.assigns.drafts
else
Map.delete(socket.assigns.drafts, active_language)
end
socket = socket =
socket socket
|> assign(:post, refreshed_post) |> assign(:post, refreshed_post)
|> assign(:drafts, Map.delete(socket.assigns.drafts, active_language)) |> assign(:drafts, drafts)
|> assign(:save_state, save_state_for_action(:save)) |> assign(:save_state, save_state_for_action(:save))
|> assign(:dirty?, false) |> assign(:dirty?, false)
|> build_data() |> build_data()
@@ -768,34 +806,56 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
post_id = socket.assigns.post_id post_id = socket.assigns.post_id
normalized_language = normalize_language(language, "") normalized_language = normalize_language(language, "")
source_language = socket.assigns.canonical_language source_language = socket.assigns.canonical_language
project_id = socket.assigns.post.project_id
parent = self()
case AI.translate_post(post_id, normalized_language, source_language: source_language) do {:ok, _task} =
{:ok, translation} -> Tasks.submit_task(
with {:ok, _saved_translation} <- "Translate Post to #{normalized_language}",
fn report ->
report.(0.1, "Translating post to #{normalized_language}")
with {:ok, translation} <-
AI.translate_post(post_id, normalized_language,
source_language: source_language
),
{:ok, _saved_translation} <-
Posts.upsert_post_translation(post_id, normalized_language, %{ Posts.upsert_post_translation(post_id, normalized_language, %{
title: translation.title, title: translation.title,
excerpt: translation.excerpt, excerpt: translation.excerpt,
content: translation.content content: translation.content
}) do }) do
socket = report.(1.0, "Post translation complete")
send(parent, {:editor_translation_completed, :post, post_id, normalized_language})
%{post_id: post_id, language: normalized_language}
else
{:error, reason} -> {:error, reason}
end
end,
%{group_id: project_id, group_name: "AI"}
)
socket socket
|> assign(:active_language, normalized_language)
|> assign(:drafts, Map.delete(socket.assigns.drafts, normalized_language))
|> assign(:quick_actions_open?, false) |> assign(:quick_actions_open?, false)
|> build_data() |> build_data()
end
end
Notify.dirty(:post, post_id, false) defp apply_translation_completed(socket, language) do
case Posts.get_post(socket.assigns.post_id) do
nil ->
build_data(socket)
%Post{} = post ->
socket =
socket socket
else |> assign(:post, post)
{:error, reason} -> |> assign(:active_language, language)
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error") |> assign(:drafts, Map.delete(socket.assigns.drafts, language))
|> build_data() |> build_data()
end
{:error, reason} -> Notify.dirty(:post, post.id, false)
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error") socket
|> build_data()
end
end end
end end
@@ -819,7 +879,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end) end)
if map_size(attrs) == 0 do if map_size(attrs) == 0 do
assign(socket, :shell_overlay, nil) socket
else else
case Posts.update_post(post_id, attrs) do case Posts.update_post(post_id, attrs) do
{:ok, updated_post} -> {:ok, updated_post} ->
@@ -837,7 +897,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
) )
|> assign(:save_state, :dirty) |> assign(:save_state, :dirty)
|> assign(:dirty?, true) |> assign(:dirty?, true)
|> assign(:shell_overlay, nil)
|> build_data() |> build_data()
Notify.dirty(:post, post_id, true) Notify.dirty(:post, post_id, true)
@@ -941,7 +1000,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
active_language = socket.assigns.active_language active_language = socket.assigns.active_language
post = socket.assigns.post post = socket.assigns.post
metadata = socket.assigns.project_metadata metadata = socket.assigns.project_metadata
draft = current_draft(socket.assigns, post, metadata, active_language) draft = component_current_draft(socket, post, metadata, active_language)
updated = Map.put(draft, field, value) updated = Map.put(draft, field, value)
assign(socket, :drafts, Map.put(socket.assigns.drafts, active_language, updated)) assign(socket, :drafts, Map.put(socket.assigns.drafts, active_language, updated))
end end

View File

@@ -1,242 +0,0 @@
defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.Posts
alias BDS.Posts.{Post, Translation}
alias BDS.Desktop.ShellLive.PostEditor.PostMetadata
alias BDS.UI.Workbench
@spec normalize_mode(term()) :: term()
def normalize_mode(mode) when mode in [:markdown, :preview], do: mode
@spec normalize_mode(term()) :: term()
def normalize_mode("visual"), do: :markdown
def normalize_mode("preview"), do: :preview
def normalize_mode(_mode), do: :markdown
@spec normalize_language(term(), term()) :: term()
def normalize_language(value, fallback) do
case value |> to_string() |> String.trim() do
"" -> fallback
normalized -> String.downcase(normalized)
end
end
@spec normalize_params(term(), term(), term()) :: term()
def normalize_params(params, current_language, next_language) do
%{
"title" => Map.get(params, "title", ""),
"excerpt" => Map.get(params, "excerpt", ""),
"content" => Map.get(params, "content", ""),
"tags" => Map.get(params, "tags", ""),
"categories" => Map.get(params, "categories", ""),
"author" => Map.get(params, "author", ""),
"language" =>
if(current_language == next_language,
do: normalize_language(Map.get(params, "language"), current_language),
else: next_language
),
"do_not_translate" => truthy?(Map.get(params, "do_not_translate")),
"template_slug" => Map.get(params, "template_slug", "")
}
end
@spec current_draft(term(), term(), term(), term()) :: term()
def current_draft(assigns, %Post{} = post, metadata, active_language) do
persisted = persisted_form(post, metadata, active_language)
assigns.post_editor_drafts
|> Map.get(post.id, %{})
|> Map.get(active_language, persisted)
end
@spec persisted_form(term(), term(), term()) :: term()
def persisted_form(%Post{} = post, metadata, active_language) do
persisted_form(post, metadata, active_language, PostMetadata.translations(post.id))
end
@spec persisted_form(term(), term(), term(), term()) :: term()
def persisted_form(post, metadata, active_language, translations) do
canonical_language = PostMetadata.canonical_language(post, metadata)
translation = Map.get(translations, active_language)
if active_language == canonical_language do
%{
"title" => post.title || "",
"excerpt" => post.excerpt || "",
"content" => Posts.editor_body(post),
"tags" => Enum.join(post.tags || [], ", "),
"categories" => Enum.join(post.categories || [], ", "),
"author" => post.author || metadata.default_author || "",
"language" => canonical_language,
"do_not_translate" => post.do_not_translate || false,
"template_slug" => post.template_slug || ""
}
else
%{
"title" => (translation && translation.title) || "",
"excerpt" => (translation && translation.excerpt) || "",
"content" => if(translation, do: Posts.editor_body(translation), else: ""),
"tags" => Enum.join(post.tags || [], ", "),
"categories" => Enum.join(post.categories || [], ", "),
"author" => post.author || metadata.default_author || "",
"language" => active_language,
"do_not_translate" => post.do_not_translate || false,
"template_slug" => post.template_slug || ""
}
end
end
@spec maybe_update_draft(term(), term(), term(), term(), term(), term(), term()) :: term()
def maybe_update_draft(socket, post_id, post, current_language, next_language, draft, true) do
workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
socket
|> assign(:workbench, workbench)
|> assign(
:post_editor_drafts,
put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)
)
|> assign(
:post_editor_active_languages,
Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)
)
|> assign(
:post_editor_save_states,
Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:post, post_id}, %{
title: draft["title"],
subtitle: Atom.to_string(post.status || :draft)
})
)
|> maybe_drop_old_language_draft(post_id, current_language, next_language)
end
def maybe_update_draft(socket, post_id, _post, _current_language, next_language, _draft, false) do
assign(
socket,
:post_editor_active_languages,
Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)
)
end
@spec put_draft_field(term(), term(), term(), term(), term(), term()) :: term()
def put_draft_field(socket, post_id, post, active_language, field, value) do
metadata = PostMetadata.project_metadata(post.project_id)
draft = Map.put(current_draft(socket.assigns, post, metadata, active_language), field, value)
workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
socket
|> assign(:workbench, workbench)
|> assign(
:post_editor_drafts,
put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, draft)
)
|> assign(
:post_editor_save_states,
Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)
)
end
@spec put_query_state(term(), term(), term(), term()) :: term()
def put_query_state(socket, post_id, kind, value) do
key = query_key(kind)
assign(
socket,
key,
Map.put(Map.get(socket.assigns, key, %{}), post_id, to_string(value || ""))
)
end
@spec query_value(term(), term(), term()) :: term()
def query_value(assigns, kind, post_id) do
assigns
|> Map.get(query_key(kind), %{})
|> Map.get(post_id, "")
end
defp query_key(:tags), do: :post_editor_tag_queries
defp query_key(:categories), do: :post_editor_category_queries
defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language)
when current_language == next_language,
do: socket
defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do
assign(
socket,
:post_editor_drafts,
delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language)
)
end
@spec toggled_sections(term(), term(), term()) :: term()
def toggled_sections(expanded_by_post, post_id, section) do
expanded_by_post
|> Map.get(post_id, %{metadata: false, excerpt: false})
|> Map.put_new(:metadata, false)
|> Map.put_new(:excerpt, false)
|> Map.update!(section, &(not &1))
end
@spec put_nested_map(term(), term(), term(), term()) :: term()
def put_nested_map(map, key, nested_key, value) do
Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value))
end
@spec delete_nested_map(term(), term(), term()) :: term()
def delete_nested_map(map, key, nested_key) do
case Map.get(map, key) do
nil ->
map
nested ->
case Map.delete(nested, nested_key) do
emptied when map_size(emptied) == 0 -> Map.delete(map, key)
remaining -> Map.put(map, key, remaining)
end
end
end
@spec reload_with_assigned_workbench(term(), term()) :: term()
def reload_with_assigned_workbench(socket, reload),
do: reload.(socket, socket.assigns.workbench)
@spec save_state_for_action(term()) :: term()
def save_state_for_action(:publish), do: :published
def save_state_for_action(_action), do: :saved
@spec record_title(term(), term()) :: term()
def record_title(%Translation{title: title}, post),
do: blank_to_nil(title) || post.title || post.slug || post.id
def record_title(%Post{title: title, slug: slug, id: id}, _post),
do: blank_to_nil(title) || blank_to_nil(slug) || id
@spec record_status(term()) :: term()
def record_status(%Translation{status: status}), do: status || :draft
def record_status(%Post{status: status}), do: status || :draft
@spec editing_canonical_language?(term(), term(), term()) :: term()
def editing_canonical_language?(translations, active_language, canonical_language) do
active_language == canonical_language or not Map.has_key?(translations, active_language)
end
defp truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
defp truthy?(_value), do: false
defp blank_to_nil(value) do
value
|> to_string()
|> String.trim()
|> case do
"" -> nil
trimmed -> trimmed
end
end
end

View File

@@ -173,9 +173,22 @@
value={@post_editor.tag_query} value={@post_editor.tag_query}
placeholder={dgettext("ui", "Add tag")} placeholder={dgettext("ui", "Add tag")}
autocomplete="off" autocomplete="off"
phx-focus="focus_post_editor_tags"
phx-target={@myself}
/> />
</div> </div>
<%= if String.trim(@post_editor.tag_query || "") == "" and Enum.any?(@post_editor.tag_semantic_suggestions) do %>
<div class="tag-suggestions tag-suggestions-semantic mt-2 flex flex-col">
<div class="tag-suggestion-section-label"><%= dgettext("ui", "Suggested tags") %></div>
<%= for name <- @post_editor.tag_semantic_suggestions do %>
<button class="tag-suggestion ai-suggested" type="button" phx-click="add_post_editor_tag" phx-value-tag={name} phx-target={@myself}>
<span class="tag-suggestion-name"><%= name %></span>
</button>
<% end %>
</div>
<% end %>
<%= if String.trim(@post_editor.tag_query || "") != "" and (Enum.any?(@post_editor.tag_suggestions) or @post_editor.tag_query_addable?) do %> <%= if String.trim(@post_editor.tag_query || "") != "" and (Enum.any?(@post_editor.tag_suggestions) or @post_editor.tag_query_addable?) do %>
<div class="tag-suggestions mt-2 flex flex-col"> <div class="tag-suggestions mt-2 flex flex-col">
<%= for tag <- @post_editor.tag_suggestions do %> <%= for tag <- @post_editor.tag_suggestions do %>
@@ -381,7 +394,7 @@
<div class="editor-toolbar-right ui-toolbar-group flex items-center gap-2"> <div class="editor-toolbar-right ui-toolbar-group flex items-center gap-2">
<%= if @post_editor.mode == :markdown do %> <%= if @post_editor.mode == :markdown do %>
<button <button
class="insert-post-link-button" class="insert-post-link-button secondary ui-button ui-button-secondary whitespace-nowrap"
data-testid="editor-toolbar-overlay-button" data-testid="editor-toolbar-overlay-button"
type="button" type="button"
phx-click="open_overlay" phx-click="open_overlay"
@@ -390,7 +403,7 @@
<%= dgettext("ui", "Insert Link") %> <%= dgettext("ui", "Insert Link") %>
</button> </button>
<button <button
class="insert-media-button" class="insert-media-button secondary ui-button ui-button-secondary whitespace-nowrap"
data-testid="editor-toolbar-overlay-button" data-testid="editor-toolbar-overlay-button"
type="button" type="button"
phx-click="open_overlay" phx-click="open_overlay"
@@ -399,7 +412,7 @@
<%= dgettext("ui", "Insert Media") %> <%= dgettext("ui", "Insert Media") %>
</button> </button>
<button <button
class="add-gallery-images-button" class="add-gallery-images-button secondary ui-button ui-button-secondary whitespace-nowrap"
type="button" type="button"
phx-click="add_gallery_images" phx-click="add_gallery_images"
phx-value-post-id={@post_editor.id} phx-value-post-id={@post_editor.id}
@@ -410,7 +423,7 @@
<%= if @post_editor.gallery_count > 0 do %> <%= if @post_editor.gallery_count > 0 do %>
<button <button
class="gallery-button" class="gallery-button secondary ui-button ui-button-secondary whitespace-nowrap"
data-testid="editor-toolbar-overlay-button" data-testid="editor-toolbar-overlay-button"
type="button" type="button"
phx-click="open_overlay" phx-click="open_overlay"

View File

@@ -206,11 +206,17 @@ defmodule BDS.Desktop.ShellLive.ScriptEditor do
%Script{} = script -> %Script{} = script ->
draft = current_draft(socket.assigns, script) draft = current_draft(socket.assigns, script)
# The sandbox runs in its own process, so streamed script output is
# routed back to this LiveView explicitly.
live_view = self()
title = dgettext("ui", "Scripts")
case Scripting.execute_project_script( case Scripting.execute_project_script(
script.project_id, script.project_id,
draft["content"] || "", draft["content"] || "",
draft["entrypoint"] || "main", draft["entrypoint"] || "main",
[] [],
on_output: fn text -> Notify.output_to(live_view, title, text, nil, "info") end
) do ) do
{:ok, result} -> {:ok, result} ->
notify_output(socket, dgettext("ui", "Scripts"), inspect(result)) notify_output(socket, dgettext("ui", "Scripts"), inspect(result))

View File

@@ -57,6 +57,27 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) :: @spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()} {:noreply, Phoenix.LiveView.Socket.t()}
@impl true @impl true
def handle_event("copy_blogmark_bookmarklet", _params, socket) do
project_id = socket.assigns.projects.active_project_id
bookmarklet = BDS.Scripting.Capabilities.AppShell.blogmark_bookmarklet(project_id: project_id)
case BDS.Scripting.Capabilities.AppShell.copy_to_clipboard(bookmarklet, []) do
true -> Notify.output(dgettext("ui", "Blogmark Bookmarklet"), dgettext("ui", "Bookmarklet copied to clipboard"), "info")
_ -> Notify.output(dgettext("ui", "Blogmark Bookmarklet"), dgettext("ui", "Failed to copy bookmarklet to clipboard"), "error")
end
{:noreply, socket}
end
def handle_event("install_cli_tool", _params, socket) do
case BDS.UI.SettingsForm.run_action("install_cli") do
{:ok, message} -> LiveToast.send_toast(:info, message)
{:error, message} -> LiveToast.send_toast(:error, message)
end
{:noreply, socket}
end
def handle_event("change_settings_search", %{"query" => query}, socket) do def handle_event("change_settings_search", %{"query" => query}, socket) do
socket = socket =
socket socket
@@ -290,7 +311,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
query, query,
~w(mcp claude copilot gemini opencode mistral codex agent server) ~w(mcp claude copilot gemini opencode mistral codex agent server)
), ),
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem)), data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem cli)),
supported_languages: @supported_languages, supported_languages: @supported_languages,
protected_categories: ManagedCategories.protected_categories() protected_categories: ManagedCategories.protected_categories()
} }

View File

@@ -4,7 +4,9 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
use Phoenix.Component use Phoenix.Component
alias BDS.AI alias BDS.AI
alias BDS.Desktop.ShellLive.Notify
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
require Logger
use Gettext, backend: BDS.Gettext use Gettext, backend: BDS.Gettext
@spec ai_form(term()) :: term() @spec ai_form(term()) :: term()
@@ -70,6 +72,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
with {:ok, endpoint} <- endpoint_refresh_attrs(endpoint_key, attrs), with {:ok, endpoint} <- endpoint_refresh_attrs(endpoint_key, attrs),
{:ok, models} <- AI.list_endpoint_models(endpoint) do {:ok, models} <- AI.list_endpoint_models(endpoint) do
notify_models_loaded(models)
socket socket
|> assign( |> assign(
:settings_editor_endpoint_models, :settings_editor_endpoint_models,
@@ -82,16 +86,45 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
{:error, reason} -> {:error, reason} ->
message = ai_error_message(reason)
Notify.alert(dgettext("ui", "Could not load models"), message)
socket socket
|> append_output.(dgettext("ui", "AI Settings"), inspect(reason), nil, "error") |> append_output.(dgettext("ui", "AI Settings"), message, nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
end end
@spec save_ai(term(), term(), term()) :: term() @spec save_ai(term(), term(), term()) :: term()
def save_ai(socket, reload, append_output) do def save_ai(socket, reload, append_output) do
do_save_ai(socket, reload, append_output)
rescue
error ->
Logger.error("AI settings save crashed: #{Exception.format(:error, error, __STACKTRACE__)}")
surface_ai_error(socket, reload, append_output, error)
end
defp do_save_ai(socket, reload, append_output) do
attrs = ai_attrs(socket.assigns) attrs = ai_attrs(socket.assigns)
case save_attrs(attrs) do
:ok ->
LiveToast.send_toast(:info, dgettext("ui", "AI settings saved."))
socket
|> assign(:settings_editor_ai_draft, %{})
|> assign(:offline_mode, attrs.offline_mode)
|> reload.(socket.assigns.workbench)
{:error, reason} ->
surface_ai_error(socket, reload, append_output, reason)
end
end
# The full save pipeline as a pure function so the TUI settings form
# (BDS.UI.SettingsForm) drives the exact same writes as the GUI editor.
@spec save_attrs(map()) :: :ok | {:error, term()}
def save_attrs(attrs) do
with :ok <- with :ok <-
put_endpoint_preferences( put_endpoint_preferences(
:online, :online,
@@ -141,18 +174,40 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
attrs.offline_chat_images attrs.offline_chat_images
), ),
:ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do :ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do
socket :ok
|> assign(:settings_editor_ai_draft, %{})
|> assign(:offline_mode, attrs.offline_mode)
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(dgettext("ui", "AI Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end end
end end
defp notify_models_loaded([]) do
LiveToast.send_toast(
:info,
dgettext(
"ui",
"The endpoint returned no models. You can still type a model name manually."
)
)
end
defp notify_models_loaded(models) do
LiveToast.send_toast(
:info,
dgettext("ui", "Loaded %{count} models.", count: length(models))
)
end
defp surface_ai_error(socket, reload, append_output, reason) do
message = ai_error_message(reason)
Notify.alert(dgettext("ui", "Could not save AI settings"), message)
socket
|> append_output.(dgettext("ui", "AI Settings"), message, nil, "error")
|> reload.(socket.assigns.workbench)
end
defp ai_error_message(%{__exception__: true} = error), do: Exception.message(error)
defp ai_error_message(reason) when is_binary(reason), do: reason
defp ai_error_message(reason), do: inspect(reason)
@spec reset_ai_prompt(term(), term(), term()) :: term() @spec reset_ai_prompt(term(), term(), term()) :: term()
def reset_ai_prompt(socket, reload, append_output) do def reset_ai_prompt(socket, reload, append_output) do
case EditorSettings.put_global_setting("ai.system_prompt", "") do case EditorSettings.put_global_setting("ai.system_prompt", "") do
@@ -171,12 +226,16 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp safe_endpoint(kind) do defp safe_endpoint(kind) do
case AI.get_endpoint(kind) do case AI.get_endpoint(kind) do
{:ok, ep} -> ep {:ok, ep} -> ep
_error -> nil error ->
Logger.debug("swallowed AI settings safe_endpoint error: #{inspect(error)}")
nil
end end
end end
defp ai_attrs(assigns) do defp ai_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_ai_draft, %{}) # Merge the stored form with the edit draft so untouched fields (URLs, API
# keys) are preserved on save instead of being read as nil and wiped.
draft = Map.merge(ai_form(assigns), Map.get(assigns, :settings_editor_ai_draft, %{}))
%{ %{
online_url: blank_to_nil(Map.get(draft, "online_url")), online_url: blank_to_nil(Map.get(draft, "online_url")),
@@ -309,7 +368,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp normalize_endpoint_result({:ok, _endpoint}), do: :ok defp normalize_endpoint_result({:ok, _endpoint}), do: :ok
defp normalize_endpoint_result({:error, reason}), do: {:error, reason} defp normalize_endpoint_result({:error, reason}), do: {:error, reason}
defp truthy?(value), do: value in [true, "true", "on", "1", 1] defp truthy?(value), do: BDS.Values.truthy?(value)
defp blank_to_nil(nil), do: nil defp blank_to_nil(nil), do: nil

View File

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

View File

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

View File

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

View File

@@ -98,7 +98,10 @@
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Bookmarklet") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Bookmarklet") %></label></div>
<div class="setting-control"><p class="setting-description"><%= dgettext("ui", "Bookmarklet copy support is wired through the desktop runtime and project public URL.") %></p></div> <div class="setting-control">
<p class="setting-description"><%= dgettext("ui", "Bookmarklet copy support is wired through the desktop runtime and project public URL.") %></p>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="copy_blogmark_bookmarklet" phx-target={@myself}><%= dgettext("ui", "Copy Bookmarklet to Clipboard") %></button>
</div>
</div> </div>
</form> </form>
<div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_project" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div> <div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_project" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div>
@@ -372,11 +375,21 @@
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_media_from_files"><%= dgettext("ui", "Rebuild Media From Files") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_media_from_files"><%= dgettext("ui", "Rebuild Media From Files") %></button>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_scripts_from_files"><%= dgettext("ui", "Rebuild Scripts From Files") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_scripts_from_files"><%= dgettext("ui", "Rebuild Scripts From Files") %></button>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_templates_from_files"><%= dgettext("ui", "Rebuild Templates From Files") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_templates_from_files"><%= dgettext("ui", "Rebuild Templates From Files") %></button>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_post_links"><%= dgettext("ui", "Rebuild Links") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_post_links"><%= dgettext("ui", "Rebuild Post Links") %></button>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_media_links"><%= dgettext("ui", "Rebuild Media Links") %></button>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="regenerate_missing_thumbnails"><%= dgettext("ui", "Regenerate Missing Thumbnails") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="regenerate_missing_thumbnails"><%= dgettext("ui", "Regenerate Missing Thumbnails") %></button>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_embedding_index"><%= dgettext("ui", "Rebuild Embedding Index") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_embedding_index"><%= dgettext("ui", "Rebuild Embedding Index") %></button>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button>
</div> </div>
<div class="setting-row">
<div class="setting-info">
<label class="setting-label"><%= dgettext("ui", "Command Line Tool") %></label>
<p class="setting-description"><%= dgettext("ui", "Install the bds-cli tool to ~/.local/bin; it uses the same settings and cache database as the app") %></p>
</div>
<div class="setting-control">
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="install_cli_tool" phx-target={@myself}><%= dgettext("ui", "Install CLI Tool") %></button>
</div>
</div>
</div> </div>
<% end %> <% end %>
</div> </div>

View File

@@ -0,0 +1,123 @@
defmodule BDS.Desktop.ShellLive.SocketState do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{PanelRenderer, SidebarState}
alias BDS.UI.Workbench
use Gettext, backend: BDS.Gettext
@output_entry_limit 20
def refresh_layout(socket, workbench) do
git_badge_count = socket.assigns[:git_badge_count] || 0
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
task_status =
socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode = Map.get(socket.assigns, :offline_mode, true)
sidebar_data = socket.assigns[:sidebar_data] || %{}
current_tab = current_tab(workbench)
prev_tab = socket.assigns[:current_tab]
prev_panel_tab =
case socket.assigns[:workbench] do
%Workbench{panel: %{active_tab: tab}} -> tab
_ -> nil
end
socket =
socket
|> assign(:workbench, workbench)
|> assign(:activity_buttons, activity_buttons)
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|> assign(:current_tab, current_tab)
|> assign(:editor_meta, ShellData.editor_meta(task_status, page_language))
|> assign(
:status,
ShellData.status_bar(workbench, task_status, dashboard,
ui_language: page_language,
offline_mode: offline_mode
)
)
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do
refresh_panel_data(socket)
else
socket
end
end
def refresh_sidebar(socket, workbench) do
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
project_id,
active_view_id,
SidebarState.current_filters(socket, active_view_id)
) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
end
sidebar_data = SidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
socket
|> assign(:sidebar_data, sidebar_data)
|> refresh_layout(workbench)
end
def append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
entry = %{title: title, message: message, details: details, level: level}
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
assign(socket, :output_entries, entries)
end
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do
current_tab != prev_tab or panel_tab != prev_panel_tab
end
defp refresh_panel_data(socket) do
panel_tab = socket.assigns.workbench.panel.active_tab
socket
|> assign(
:panel_post_links,
if(panel_tab == :post_links,
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
)
)
|> assign(
:panel_git_entries,
if(panel_tab == :git_log,
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
else: socket.assigns[:panel_git_entries] || []
)
)
end
defp activity_label("AI Assistant"), do: dgettext("ui", "Chat")
defp activity_label("Source Control"), do: dgettext("ui", "Git")
defp activity_label(label), do: label
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
Enum.find_value(activity_buttons, Map.get(sidebar_data, :title, ""), fn button ->
if button.id == active_view, do: activity_label(button.label), else: nil
end)
end
defp current_tab(%{active_tab: nil}), do: nil
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
Enum.find(tabs, &(&1.type == type and &1.id == id))
end
end

View File

@@ -0,0 +1,72 @@
defmodule BDS.Desktop.ShellLive.TabActions do
@moduledoc false
alias BDS.Desktop.ShellLive.{
MediaEditor,
MenuEditor,
PostEditor,
ScriptEditor,
SocketState,
SettingsEditor,
TagsEditor,
TemplateEditor
}
alias BDS.UI.Workbench
def auto_save_current_post(
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
) do
if Workbench.dirty?(workbench, :post, post_id) do
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :auto_save)
end
socket
end
def auto_save_current_post(socket), do: socket
def save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
Phoenix.LiveView.send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
Phoenix.LiveView.send_update(SettingsEditor, id: "settings-editor", action: :save_project)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
Phoenix.LiveView.send_update(MenuEditor, id: "menu-editor", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
Phoenix.LiveView.send_update(TagsEditor, id: "tags-editor", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
Phoenix.LiveView.send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
socket
end
def save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
Phoenix.LiveView.send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
socket
end
def save_current_tab(socket), do: SocketState.refresh_layout(socket, socket.assigns.workbench)
def publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
socket
end
def publish_current_tab(socket), do: SocketState.refresh_layout(socket, socket.assigns.workbench)
end

View File

@@ -7,7 +7,6 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
alias BDS.{Repo, Tags} alias BDS.{Repo, Tags}
alias BDS.Desktop.ShellLive.Notify alias BDS.Desktop.ShellLive.Notify
alias BDS.Posts.Post
alias BDS.Tags.Tag alias BDS.Tags.Tag
alias BDS.Templates.Template alias BDS.Templates.Template
use Gettext, backend: BDS.Gettext use Gettext, backend: BDS.Gettext
@@ -16,12 +15,8 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
@tags_sections ~w(cloud manage merge) @tags_sections ~w(cloud manage merge)
@colour_presets ~w( # Shared with the TUI tags panel (issue #34) via BDS.UI.TagsPanel.
#ef4444 #f97316 #f59e0b #eab308 #84cc16 @colour_presets BDS.UI.TagsPanel.colour_presets()
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
#ec4899 #64748b
)
@spec colour_presets() :: [String.t()] @spec colour_presets() :: [String.t()]
def colour_presets, do: @colour_presets def colour_presets, do: @colour_presets
@@ -439,12 +434,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
defp current_tab_meta(_assigns), do: %{} defp current_tab_meta(_assigns), do: %{}
defp tag_counts(project_id) do defp tag_counts(project_id), do: BDS.UI.TagsPanel.tag_counts(project_id)
Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.tags)
|> List.flatten()
|> Enum.reject(&is_nil/1)
|> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
end
defp blank_to_nil(nil), do: nil defp blank_to_nil(nil), do: nil

View File

@@ -0,0 +1,78 @@
defmodule BDS.Desktop.ShellLive.UrlState do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.BoundedAtoms
alias BDS.Desktop.ShellLive.{SocketState, TabHelpers}
alias BDS.UI.Workbench
def push(socket) do
workbench = socket.assigns.workbench
params =
%{}
|> put_url_view(workbench.active_view)
|> put_url_tab(workbench.active_tab)
query = URI.encode_query(params)
path = if query == "", do: "/", else: "/?" <> query
Phoenix.LiveView.push_event(socket, "url-state", %{path: path})
end
def apply_params(socket, params) when is_map(params) and map_size(params) > 0 do
workbench = socket.assigns.workbench
workbench = apply_url_view(workbench, Map.get(params, "view"))
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
if workbench == socket.assigns.workbench do
socket
else
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
socket
|> assign(:tab_meta, tab_meta)
|> SocketState.refresh_sidebar(workbench)
end
end
def apply_params(socket, _params), do: socket
defp put_url_view(params, :posts), do: params
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
defp put_url_tab(params, nil), do: params
defp put_url_tab(params, {type, id}), do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
defp apply_url_view(workbench, nil), do: workbench
defp apply_url_view(workbench, view_str) do
view = BoundedAtoms.sidebar_view(view_str, nil)
if view && view != workbench.active_view do
Workbench.click_activity(workbench, view)
else
workbench
end
end
defp apply_url_tab(workbench, nil), do: workbench
defp apply_url_tab(workbench, tab_str) do
case String.split(tab_str, ":", parts: 2) do
[type_str, id] ->
type = BoundedAtoms.editor_route(type_str, nil)
if type && workbench.active_tab != {type, id} do
Workbench.open_tab(workbench, type, id, :preview)
else
workbench
end
_ ->
workbench
end
end
end

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.Shutdown do
alias Desktop.Wx alias Desktop.Wx
alias Desktop.Window alias Desktop.Window
require Logger
require Record require Record
Record.defrecordp(:wx, Record.extract(:wx, from_lib: "wx/include/wx.hrl")) Record.defrecordp(:wx, Record.extract(:wx, from_lib: "wx/include/wx.hrl"))
@@ -25,9 +26,13 @@ defmodule BDS.Desktop.Shutdown do
:ok :ok
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed shutdown install_handlers error: #{inspect(error)}")
:ok
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed shutdown install_handlers exit: #{inspect(reason)}")
:ok
end end
@spec request_quit() :: :ok @spec request_quit() :: :ok
@@ -102,9 +107,13 @@ defmodule BDS.Desktop.Shutdown do
fun.() fun.()
:ok :ok
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed shutdown persist_step error: #{inspect(error)}")
:ok
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed shutdown persist_step exit: #{inspect(reason)}")
:ok
end end
# heart, when present, would relaunch the app after we kill the BEAM, so it # heart, when present, would relaunch the app after we kill the BEAM, so it
@@ -120,9 +129,13 @@ defmodule BDS.Desktop.Shutdown do
_ -> :ok _ -> :ok
end end
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed shutdown kill_heart error: #{inspect(error)}")
:ok
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed shutdown kill_heart exit: #{inspect(reason)}")
:ok
end end
defp kill_beam do defp kill_beam do
@@ -133,9 +146,13 @@ defmodule BDS.Desktop.Shutdown do
os_kill_fun().(os_pid) os_kill_fun().(os_pid)
:ok :ok
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed shutdown os_kill error: #{inspect(error)}")
:ok
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed shutdown os_kill exit: #{inspect(reason)}")
:ok
end end
defp os_kill_fun do defp os_kill_fun do
@@ -158,9 +175,13 @@ defmodule BDS.Desktop.Shutdown do
:ok :ok
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed shutdown maybe_hide_window error: #{inspect(error)}")
:ok
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed shutdown maybe_hide_window exit: #{inspect(reason)}")
:ok
end end
@doc false @doc false

View File

@@ -1,9 +1,22 @@
defmodule BDS.Embeddings do defmodule BDS.Embeddings do
@moduledoc false @moduledoc """
Context for post embeddings: build and refresh the per-project vector index,
sync individual posts, report indexing progress, and compute similarity.
The backing index is persisted to disk and rebuilt on demand; a failed persist
is retried by the next reindex.
"""
import Ecto.Query import Ecto.Query
require Logger require Logger
@type project_id :: String.t()
@type post_id :: String.t()
@type progress_opts :: keyword()
@type similarity_map :: %{optional(post_id()) => float()}
@type indexed_posts_result :: {:ok, [post_id()]} | {:error, term()}
@type duplicate_pair_ids :: [{post_id(), post_id()}]
alias BDS.Persistence alias BDS.Persistence
alias BDS.Embeddings.DismissedDuplicatePair alias BDS.Embeddings.DismissedDuplicatePair
alias BDS.Embeddings.Index alias BDS.Embeddings.Index
@@ -18,11 +31,19 @@ defmodule BDS.Embeddings do
@exact_match_score 0.999999 @exact_match_score 0.999999
@key_batch_size 199 @key_batch_size 199
@spec model_id() :: term()
def model_id, do: configured_backend().model_info().model_id def model_id, do: configured_backend().model_info().model_id
@spec dimensions() :: pos_integer()
def dimensions, do: configured_backend().model_info().dimensions def dimensions, do: configured_backend().model_info().dimensions
@spec index_path(project_id()) :: String.t()
def index_path(project_id), do: Index.path(project_id) def index_path(project_id), do: Index.path(project_id)
@spec reindex_all(project_id()) :: indexed_posts_result()
def reindex_all(project_id), do: rebuild_project(project_id) def reindex_all(project_id), do: rebuild_project(project_id)
@spec refresh_snapshot(project_id()) :: :ok
def refresh_snapshot(project_id) when is_binary(project_id) do def refresh_snapshot(project_id) when is_binary(project_id) do
if enabled_for_project?(project_id) do if enabled_for_project?(project_id) do
:ok = rebuild_snapshot(project_id) :ok = rebuild_snapshot(project_id)
@@ -31,6 +52,8 @@ defmodule BDS.Embeddings do
:ok :ok
end end
@spec get_indexing_progress(project_id()) ::
{:ok, %{indexed: non_neg_integer(), total: non_neg_integer()}}
def get_indexing_progress(project_id) when is_binary(project_id) do def get_indexing_progress(project_id) when is_binary(project_id) do
indexed = indexed =
Repo.one( Repo.one(
@@ -49,6 +72,7 @@ defmodule BDS.Embeddings do
{:ok, %{indexed: indexed, total: total}} {:ok, %{indexed: indexed, total: total}}
end end
@spec sync_post(Post.t() | post_id()) :: :ok
def sync_post(%Post{} = post) do def sync_post(%Post{} = post) do
if enabled_for_project?(post.project_id) do if enabled_for_project?(post.project_id) do
sync_post_if_enabled(post, refresh_index: true) sync_post_if_enabled(post, refresh_index: true)
@@ -64,6 +88,7 @@ defmodule BDS.Embeddings do
end end
end end
@spec repair_posts(project_id(), [post_id()]) :: indexed_posts_result()
def repair_posts(project_id, post_ids) when is_binary(project_id) and is_list(post_ids) do def repair_posts(project_id, post_ids) when is_binary(project_id) and is_list(post_ids) do
if enabled_for_project?(project_id) do if enabled_for_project?(project_id) do
post_ids = Enum.uniq(post_ids) post_ids = Enum.uniq(post_ids)
@@ -91,6 +116,8 @@ defmodule BDS.Embeddings do
end end
end end
@spec rebuild_project(project_id()) :: indexed_posts_result()
@spec rebuild_project(project_id(), progress_opts()) :: indexed_posts_result()
def rebuild_project(project_id, opts \\ []) def rebuild_project(project_id, opts \\ [])
def rebuild_project(project_id, opts) when is_binary(project_id) and is_list(opts) do def rebuild_project(project_id, opts) when is_binary(project_id) and is_list(opts) do
@@ -130,6 +157,7 @@ defmodule BDS.Embeddings do
end end
end end
@spec diff_reports(project_id()) :: [map()]
def diff_reports(project_id) when is_binary(project_id) do def diff_reports(project_id) when is_binary(project_id) do
if enabled_for_project?(project_id) do if enabled_for_project?(project_id) do
keys_by_post = keys_by_post =
@@ -347,6 +375,7 @@ defmodule BDS.Embeddings do
end) end)
end end
@spec remove_post(post_id()) :: :ok
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 =
case Repo.get_by(Key, post_id: post_id) do case Repo.get_by(Key, post_id: post_id) do
@@ -369,6 +398,7 @@ defmodule BDS.Embeddings do
:ok :ok
end end
@spec index_unindexed(project_id()) :: indexed_posts_result()
def index_unindexed(project_id) when is_binary(project_id) do def index_unindexed(project_id) when is_binary(project_id) do
if enabled_for_project?(project_id) do if enabled_for_project?(project_id) do
posts = posts =
@@ -398,6 +428,8 @@ defmodule BDS.Embeddings do
end end
end end
@spec find_similar(post_id()) :: {:ok, [map()]}
@spec find_similar(post_id(), pos_integer()) :: {:ok, [map()]}
def find_similar(post_id, limit \\ 5) when is_binary(post_id) and is_integer(limit) do def find_similar(post_id, limit \\ 5) when is_binary(post_id) and is_integer(limit) do
case source_post_and_vector(post_id) do case source_post_and_vector(post_id) do
{:disabled, _project_id} -> {:disabled, _project_id} ->
@@ -431,6 +463,7 @@ defmodule BDS.Embeddings do
end end
end end
@spec compute_similarities(post_id(), [post_id()]) :: {:ok, similarity_map()}
def compute_similarities(source_post_id, target_post_ids) def compute_similarities(source_post_id, target_post_ids)
when is_binary(source_post_id) and is_list(target_post_ids) do when is_binary(source_post_id) and is_list(target_post_ids) do
case source_post_and_vector(source_post_id) do case source_post_and_vector(source_post_id) do
@@ -468,6 +501,7 @@ defmodule BDS.Embeddings do
end end
end end
@spec suggest_tags(post_id(), term()) :: {:ok, [String.t()]}
def suggest_tags(post_id, _input_text) when is_binary(post_id) do def suggest_tags(post_id, _input_text) when is_binary(post_id) do
with {:ok, _post} <- fetch_post(post_id), with {:ok, _post} <- fetch_post(post_id),
{:ok, similar} <- find_similar(post_id, 10) do {:ok, similar} <- find_similar(post_id, 10) do
@@ -497,6 +531,8 @@ defmodule BDS.Embeddings do
end end
end end
@spec find_duplicates(project_id()) :: {:ok, [map()]}
@spec find_duplicates(project_id(), progress_opts()) :: {:ok, [map()]}
def find_duplicates(project_id, opts \\ []) when is_binary(project_id) do def find_duplicates(project_id, opts \\ []) when is_binary(project_id) 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)
@@ -521,6 +557,8 @@ defmodule BDS.Embeddings do
end end
end end
@spec dismiss_duplicate_pair(post_id(), post_id()) ::
{:ok, DismissedDuplicatePair.t()} | {:error, :not_found}
def dismiss_duplicate_pair(post_id_a, post_id_b) def dismiss_duplicate_pair(post_id_a, post_id_b)
when is_binary(post_id_a) and is_binary(post_id_b) do when is_binary(post_id_a) and is_binary(post_id_b) do
with {:ok, post_a} <- fetch_post(post_id_a), with {:ok, post_a} <- fetch_post(post_id_a),
@@ -552,6 +590,8 @@ defmodule BDS.Embeddings do
end end
end end
@spec dismiss_duplicate_pairs(duplicate_pair_ids()) ::
{:ok, [DismissedDuplicatePair.t()]} | {:error, term()}
def dismiss_duplicate_pairs(pair_ids) when is_list(pair_ids) do def dismiss_duplicate_pairs(pair_ids) when is_list(pair_ids) do
pair_ids pair_ids
|> Enum.filter(fn |> Enum.filter(fn

View File

@@ -32,6 +32,11 @@ defmodule BDS.Embeddings.Backends.Neural do
* Everywhere else — and as a fallback when EMLX is unavailable or explicitly * Everywhere else — and as a fallback when EMLX is unavailable or explicitly
disabled — it runs on optimised native CPU via XLA (`compiler: EXLA`). disabled — it runs on optimised native CPU via XLA (`compiler: EXLA`).
Packaged releases contain only the backend that can run on their platform
(`runtime:` conditional deps in mix.exs): macOS releases ship EMLX and no
EXLA, Linux/Windows ship EXLA and no EMLX (MLX is Apple-only). The selected
backend is therefore started on demand here, never assumed at boot.
The accelerator can be pinned with `config :bds, :embeddings, accelerator:` The accelerator can be pinned with `config :bds, :embeddings, accelerator:`
to `:auto` (default), `:emlx`, or `:exla`. to `:auto` (default), `:emlx`, or `:exla`.
""" """
@@ -108,7 +113,10 @@ defmodule BDS.Embeddings.Backends.Neural do
{:reply, error, state} {:reply, error, state}
end end
rescue rescue
exception -> # Only genuine Nx/serving runtime errors become {:error, _}. Programmer bugs
# (MatchError, FunctionClauseError, …) are not caught here so they crash the
# supervised GenServer and surface unmodified instead of being repackaged.
exception in [ArgumentError, RuntimeError] ->
{:reply, {:error, Exception.message(exception)}, state} {:reply, {:error, Exception.message(exception)}, state}
end end
@@ -168,7 +176,20 @@ defmodule BDS.Embeddings.Backends.Neural do
@doc false @doc false
@spec current_accelerator() :: :emlx | :exla @spec current_accelerator() :: :emlx | :exla
def current_accelerator do def current_accelerator do
select_accelerator(configured_accelerator(), emlx_available?(), apple_silicon?()) case select_accelerator(
configured_accelerator(),
emlx_available?(),
exla_available?(),
apple_silicon?()
) do
:none ->
raise RuntimeError,
"no embeddings accelerator available: neither the EMLX (Apple GPU) nor the " <>
"EXLA (CPU) application could be started on this machine"
accelerator ->
accelerator
end
end end
@doc """ @doc """
@@ -176,22 +197,43 @@ defmodule BDS.Embeddings.Backends.Neural do
Prefer the Apple GPU (EMLX) under `:auto` only when it is both available and 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 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 degrade to the other backend when the requested one is not available so a
host still gets working CPU inference instead of crashing. misconfigured host still gets working inference instead of crashing. Only
one backend ships per platform (macOS releases exclude EXLA, Linux/Windows
exclude EMLX), so the unavailable side is the norm in production, not an
edge case.
""" """
@spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean()) :: :emlx | :exla @spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean(), boolean()) ::
def select_accelerator(:exla, _emlx_available?, _apple_silicon?), do: :exla :emlx | :exla | :none
def select_accelerator(:emlx, true, _apple_silicon?), do: :emlx def select_accelerator(_configured, false, false, _apple_silicon?), do: :none
def select_accelerator(:emlx, false, _apple_silicon?), do: :exla def select_accelerator(:exla, _emlx?, true, _apple_silicon?), do: :exla
def select_accelerator(:auto, true, true), do: :emlx def select_accelerator(:exla, true, false, _apple_silicon?), do: :emlx
def select_accelerator(:auto, _emlx_available?, _apple_silicon?), do: :exla def select_accelerator(:emlx, true, _exla?, _apple_silicon?), do: :emlx
def select_accelerator(:emlx, false, true, _apple_silicon?), do: :exla
def select_accelerator(:auto, true, _exla?, true), do: :emlx
def select_accelerator(:auto, _emlx?, true, _apple_silicon?), do: :exla
def select_accelerator(:auto, true, false, _apple_silicon?), do: :emlx
defp configured_accelerator do defp configured_accelerator do
config() |> Keyword.get(:accelerator, @default_accelerator) config() |> Keyword.get(:accelerator, @default_accelerator)
end end
# The backend apps are runtime: false deps on the platforms that can't run
# them, so releases exclude one of them entirely and neither is auto-started
# in dev/test. Availability therefore means "the app actually starts here":
# ensure_all_started errors when the app is absent (or its NIF can't load).
# Starting the app is also exactly what the chosen backend needs before
# building the serving.
defp emlx_available? do defp emlx_available? do
Code.ensure_loaded?(EMLX) and Code.ensure_loaded?(EMLX.Backend) started?(:emlx) and Code.ensure_loaded?(EMLX.Backend)
end
defp exla_available? do
started?(:exla)
end
defp started?(app) do
match?({:ok, _apps}, Application.ensure_all_started(app))
end end
defp apple_silicon? do defp apple_silicon? do

View File

@@ -7,6 +7,15 @@ defmodule BDS.Embeddings.DismissedDuplicatePair do
@primary_key {:id, :string, autogenerate: false} @primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string @foreign_key_type :string
@type t :: %__MODULE__{
id: String.t() | nil,
project_id: String.t() | nil,
project: term(),
post_id_a: String.t() | nil,
post_id_b: String.t() | nil,
dismissed_at: integer() | nil
}
schema "dismissed_duplicate_pairs" do schema "dismissed_duplicate_pairs" do
belongs_to :project, BDS.Projects.Project, type: :string belongs_to :project, BDS.Projects.Project, type: :string
field :post_id_a, :string field :post_id_a, :string

View File

@@ -23,6 +23,7 @@ defmodule BDS.Embeddings.Index do
alias BDS.Projects alias BDS.Projects
alias BDS.ProgressReporter alias BDS.ProgressReporter
require Logger
@neighbor_limit 21 @neighbor_limit 21
@debounce_ms 5_000 @debounce_ms 5_000
@@ -345,7 +346,14 @@ defmodule BDS.Embeddings.Index do
write_meta(index_path, dim, labels) write_meta(index_path, dim, labels)
:ok :ok
rescue rescue
_exception -> :ok exception ->
# Logged at :error (not :debug) so a real persist failure — or a programmer
# bug surfacing as an exception — is visible; next reindex retries the save.
Logger.error(
"embeddings index persist failed for #{project_id}: #{inspect(exception)}"
)
:ok
end end
defp write_meta(index_path, dim, labels) do defp write_meta(index_path, dim, labels) do
@@ -385,7 +393,12 @@ defmodule BDS.Embeddings.Index do
_other -> :error _other -> :error
end end
rescue rescue
_exception -> :error exception ->
Logger.error(
"embeddings index load_from_disk failed for #{project_id}: #{inspect(exception)}"
)
:error
end end
defp read_meta(index_path) do defp read_meta(index_path) do

58
lib/bds/events.ex Normal file
View File

@@ -0,0 +1,58 @@
defmodule BDS.Events do
@moduledoc """
Domain event bus (issue #26, phase 2).
Contexts broadcast entity mutations on the same `"entity:changed"` topic
(and payload shape) that `BDS.CliSync.Watcher` already uses for external
CLI writes, so every connected client — LiveView shells and TUI sessions
alike — stays synchronized through one subscription regardless of where
a change originated.
"""
@entity_topic "entity:changed"
@settings_topic "settings:changed"
@type action :: :created | :updated | :deleted
@doc "Subscribe the calling process to entity and settings changes."
@spec subscribe() :: :ok
def subscribe do
:ok = Phoenix.PubSub.subscribe(BDS.PubSub, @entity_topic)
:ok = Phoenix.PubSub.subscribe(BDS.PubSub, @settings_topic)
end
@doc """
Broadcast an entity mutation as `{:entity_changed, %{entity: entity,
entity_id: id, action: action}}` — the same message the CLI sync watcher
emits, so subscribers handle both through one code path.
"""
@spec entity_changed(String.t(), String.t(), action()) :: :ok
def entity_changed(entity, entity_id, action)
when is_binary(entity) and is_binary(entity_id) and
action in [:created, :updated, :deleted] do
Phoenix.PubSub.broadcast(
BDS.PubSub,
@entity_topic,
{:entity_changed, %{entity: entity, entity_id: entity_id, action: action}}
)
end
@doc """
Pipe-friendly broadcast: on `{:ok, %{id: id}}` broadcasts `action` for
`entity` and returns the result unchanged; any other result passes
through untouched.
"""
@spec broadcast_result(term(), String.t(), action()) :: term()
def broadcast_result({:ok, %{id: id}} = result, entity, action) when is_binary(id) do
:ok = entity_changed(entity, id, action)
result
end
def broadcast_result(result, _entity, _action), do: result
@doc "Broadcast a global setting change as `{:settings_changed, key}`."
@spec settings_changed(String.t()) :: :ok
def settings_changed(key) when is_binary(key) do
Phoenix.PubSub.broadcast(BDS.PubSub, @settings_topic, {:settings_changed, key})
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -74,8 +74,8 @@ defmodule BDS.Generation.Data do
|> Enum.uniq_by(& &1.id) |> Enum.uniq_by(& &1.id)
|> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)}) |> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)})
{published_route_posts, translations_by_post} = translations_by_post =
build_generation_route_posts( build_generation_translations(
plan.project_id, plan.project_id,
project_data_dir, project_data_dir,
published_posts, published_posts,
@@ -87,7 +87,6 @@ defmodule BDS.Generation.Data do
project_data_dir: project_data_dir, project_data_dir: project_data_dir,
published_posts: published_posts, published_posts: published_posts,
published_list_posts: published_list_posts, published_list_posts: published_list_posts,
published_route_posts: published_route_posts,
translations_by_post: translations_by_post, translations_by_post: translations_by_post,
post_index: build_generation_post_index(published_list_posts) post_index: build_generation_post_index(published_list_posts)
} }
@@ -121,14 +120,9 @@ defmodule BDS.Generation.Data do
post_language = String.downcase(to_string(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 if effective_language == target do
is_binary(Map.get(post, :translation_source_slug)) ->
post post
else
effective_language == target ->
post
true ->
case Map.get(translations_by_post_language, {post.id, target_language}) do case Map.get(translations_by_post_language, {post.id, target_language}) do
nil -> post nil -> post
translation -> build_localized_subtree_variant(post, translation) translation -> build_localized_subtree_variant(post, translation)
@@ -175,6 +169,7 @@ defmodule BDS.Generation.Data do
end) end)
end end
) )
|> sort_generation_index_posts()
end end
## --- internals ----------------------------------------------------------- ## --- internals -----------------------------------------------------------
@@ -230,7 +225,7 @@ defmodule BDS.Generation.Data do
read_snapshot(full_path, fallback_post, &build_post_snapshot/2) read_snapshot(full_path, fallback_post, &build_post_snapshot/2)
end end
defp build_generation_route_posts( defp build_generation_translations(
project_id, project_id,
project_data_dir, project_data_dir,
published_posts, published_posts,
@@ -267,17 +262,7 @@ defmodule BDS.Generation.Data do
end) end)
|> Map.new(fn {post_id, translations} -> {post_id, Enum.reverse(translations)} end) |> Map.new(fn {post_id, translations} -> {post_id, Enum.reverse(translations)} end)
route_posts =
Enum.flat_map(published_posts, fn post ->
variants =
translations_by_post translations_by_post
|> Map.get(post.id, [])
|> Enum.map(&build_published_translation_variant(post, &1))
[post | variants]
end)
{route_posts, translations_by_post}
end end
defp published_translation_snapshot(project_data_dir, %Translation{} = translation) do defp published_translation_snapshot(project_data_dir, %Translation{} = translation) do
@@ -356,31 +341,6 @@ defmodule BDS.Generation.Data do
} }
end end
defp build_published_translation_variant(post, translation) do
%{
id: translation.id,
project_id: post.project_id,
title: translation.title,
slug: "#{post.slug}.#{translation.language}",
excerpt: translation.excerpt,
content: nil,
status: :published,
author: post.author,
created_at: post.created_at,
updated_at: translation.updated_at,
published_at: translation.published_at || post.published_at,
file_path: translation.file_path,
tags: post.tags,
categories: post.categories,
template_slug: post.template_slug,
language: translation.language,
do_not_translate: post.do_not_translate,
translation_source_slug: post.slug,
translation_canonical_language: post.language,
translation_file_path: translation.file_path
}
end
defp build_localized_subtree_variant(post, translation) do defp build_localized_subtree_variant(post, translation) do
%{ %{
post post
@@ -398,4 +358,17 @@ defmodule BDS.Generation.Data do
defp append_generation_index(index, field, key, post) do defp append_generation_index(index, field, key, post) do
update_in(index[field], fn grouped -> Map.update(grouped, key, [post], &[post | &1]) end) update_in(index[field], fn grouped -> Map.update(grouped, key, [post], &[post | &1]) end)
end end
# Archive listings (categories, tags, dates) must render newest first. The
# index is built by prepending, so each grouped list is sorted explicitly by
# creation date (descending) to guarantee a stable newest-first order.
defp sort_generation_index_posts(index) do
Map.new(index, fn {field, grouped} ->
{field, Map.new(grouped, fn {key, posts} -> {key, sort_index_posts_desc(posts)} end)}
end)
end
defp sort_index_posts_desc(posts) do
Enum.sort_by(posts, &{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)})
end
end end

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