Compare commits

...

34 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
122 changed files with 15644 additions and 1879 deletions

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 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
- 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.
---

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_system_language](#appget_system_language)
- [app.get_title_bar_metrics](#appget_title_bar_metrics)
- [app.log](#applog)
- [app.notify_renderer_ready](#appnotify_renderer_ready)
- [app.open_folder](#appopen_folder)
- [app.read_project_metadata](#appread_project_metadata)
@@ -150,7 +151,7 @@ local result = bds.app.get_default_project_path()
### 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**
@@ -202,6 +203,30 @@ nil -- or
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
Notify the host application that the renderer is ready.
@@ -3443,7 +3468,7 @@ local result = bds.meta.sync_on_startup()
### 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**

View File

@@ -23,6 +23,7 @@
- [Generating and publishing](#generating-and-publishing)
- [Typical editorial workflows](#typical-editorial-workflows)
- [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)
- [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
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).
- 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.
- 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.
@@ -156,6 +157,70 @@ mix dialyzer
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

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

@@ -49,8 +49,7 @@
border-radius: 3px;
}
.editor-toolbar-button:hover,
.panel-tab:hover {
.editor-toolbar-button:hover {
background: var(--vscode-toolbar-hoverBackground);
}

View File

@@ -377,6 +377,76 @@
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 {

View File

@@ -8,14 +8,11 @@
display: none;
}
.panel-tabs {
display: flex;
gap: 8px;
}
.panel-tabs {
display: flex;
gap: 2px;
align-items: stretch;
height: 100%;
}
.panel-tab {
@@ -246,6 +243,12 @@
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 {
padding: 8px;
border-bottom: none;

View File

@@ -402,24 +402,6 @@
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 {
background: transparent;
border: none;
@@ -778,11 +760,6 @@
padding-left: 18px;
}
.editor-toolbar {
display: flex;
gap: 10px;
}
.editor-meta {
display: flex;
flex-direction: column;
@@ -794,13 +771,3 @@
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

@@ -19,6 +19,7 @@ import {
} from "../utils/shortcuts.js";
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
import { copyTextToClipboard } from "../utils/clipboard.js";
export const AppShell = {
mounted() {
@@ -179,6 +180,10 @@ export const AppShell = {
}
});
this.handleEvent("clipboard", ({ text }) => {
copyTextToClipboard(text || "");
});
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
this.el.addEventListener("load", this.handleThumbnailLoad, true);

View File

@@ -34,6 +34,20 @@ export const webKitTextAreaArrowCommand = (event) => {
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;
@@ -92,8 +106,12 @@ export const MonacoEditor = {
if (this.editor.getValue() !== value) {
this.isApplyingRemoteUpdate = true;
this.editor.setValue(value);
this.isApplyingRemoteUpdate = false;
try {
applyRemoteEditorValue(this.editor, value);
} finally {
this.isApplyingRemoteUpdate = false;
}
}
this.lastKnownValue = value;

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

@@ -25,8 +25,13 @@ config :bds, :desktop,
window_min_size: {800, 600},
title: "Blogging Desktop Server"
config :bds, :server, ssh_port: 2222
config :bds, BDS.Desktop.Endpoint,
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,
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
pubsub_server: BDS.PubSub,

View File

@@ -1,5 +1,14 @@
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
database_path =
System.get_env("BDS_DATABASE_PATH") ||

View File

@@ -5,6 +5,27 @@ defmodule BDS.Application do
@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(:test) do
@@ -41,7 +62,7 @@ defmodule BDS.Application do
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
BDS.Scripting.JobSupervisor,
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]
Supervisor.start_link(children, opts)
@@ -61,6 +82,20 @@ defmodule BDS.Application do
Application.get_env(:bds, :current_env_override) || @compiled_env
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
if desktop_automation?() do
[]
@@ -77,7 +112,8 @@ defmodule BDS.Application do
[
{Desktop.Window, window_opts},
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher),
{BDS.Desktop.DeepLink, []}
{BDS.Desktop.DeepLink, []},
{BDS.Desktop.RemoteConnection, []}
]
end
end

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

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

@@ -23,7 +23,8 @@ defmodule BDS.Desktop.Endpoint do
plug(BDS.Desktop.Router)
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
else
Desktop.Auth.call(conn, [])

View File

@@ -67,6 +67,35 @@ defmodule BDS.Desktop.MenuBar do
{:noreply, menu}
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
_ = BDS.Desktop.ShellCommands.execute("open_data_folder")
{:noreply, menu}
@@ -186,6 +215,8 @@ defmodule BDS.Desktop.MenuBar do
defp item_label(:import_media), do: dgettext("ui", "Import Media")
defp item_label(:save), do: dgettext("ui", "Save")
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(:close_tab), do: dgettext("ui", "Close Tab")
defp item_label(:quit), do: dgettext("ui", "Quit")

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

@@ -120,7 +120,7 @@ defmodule BDS.Desktop.ShellCommands do
"rebuild_embedding_index",
"Rebuild Embedding Index",
"Embeddings",
fn report -> rebuild_embedding_index_work(project, report) end
fn report -> Maintenance.rebuild_embedding_index(project.id, on_progress: report) end
)
end
@@ -221,7 +221,7 @@ defmodule BDS.Desktop.ShellCommands do
defp dispatch("rebuild_database", project, _params) do
group_id = task_group_id("rebuild_database")
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} =
Tasks.submit_task(first_step.name, first_step.work, attrs)
@@ -692,102 +692,6 @@ defmodule BDS.Desktop.ShellCommands do
|> length() > 1
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, [step | remaining_steps]) do

View File

@@ -91,14 +91,16 @@ defmodule BDS.Desktop.ShellData do
end
end
def editor_meta(task_status) 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", "Status"),
value: task_status.running_task_message || dgettext("ui", "Idle")
},
%{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
@@ -107,7 +109,7 @@ defmodule BDS.Desktop.ShellData do
post_count: dashboard.post_stats.total_posts,
media_count: dashboard.media_stats.media_count,
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),
running_task_message: task_status.running_task_message,
running_task_overflow: task_status.running_task_overflow,

View File

@@ -6,7 +6,6 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
alias BDS.CliSync.Watcher
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData, UILocale}
alias BDS.Desktop.ShellLive.{
@@ -109,7 +108,9 @@ defmodule BDS.Desktop.ShellLive do
:quit,
:view_on_github,
:report_issue,
:about
:about,
:connect_server,
:disconnect_server
])
@runtime_menu_actions MapSet.new([
:undo,
@@ -151,7 +152,9 @@ defmodule BDS.Desktop.ShellLive do
connected = connected?(socket)
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)
end
@@ -179,6 +182,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:shell_overlay, nil)
|> assign(:git_run, nil)
|> assign(:output_entries, [])
|> assign(:collapsed_task_groups, MapSet.new())
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|> assign(:panel_git_entries, [])
|> assign(:auto_save_timers, %{})
@@ -223,6 +227,38 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, refresh_layout(socket, workbench)}
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
{:noreply, open_sidebar_item(socket, params, :preview)}
end
@@ -736,6 +772,14 @@ defmodule BDS.Desktop.ShellLive do
{: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
Bridges.handle_info(message, socket, bridges_callbacks())
end
@@ -849,33 +893,66 @@ defmodule BDS.Desktop.ShellLive do
end
defp import_blogmark(socket, project_id, url, title) do
case Blogmark.receive_deep_link(project_id, url) do
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
socket
|> reload_shell(socket.assigns.workbench)
|> open_sidebar_item(
%{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => post.slug
},
:pin
)
|> append_blogmark_toasts(title, toasts)
|> append_blogmark_errors(title, errors)
# 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)
{:error, reason} ->
append_output_entry(socket, title, inspect(reason), url, "error")
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}} ->
script_output = Agent.get(output_sink, &Enum.reverse/1)
socket
|> reload_shell(socket.assigns.workbench)
|> open_sidebar_item(
%{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => post.slug
},
:pin
)
|> append_blogmark_script_output(title, script_output)
|> append_blogmark_toasts(title, toasts)
|> append_blogmark_errors(title, errors)
|> open_output_panel_when(script_output != [] or errors != [])
{:error, reason} ->
append_output_entry(socket, title, inspect(reason), url, "error")
end
after
Agent.stop(output_sink)
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
Enum.reduce(toasts, socket, fn message, acc ->
append_output_entry(acc, title, message, nil, "info")
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
Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc ->
append_output_entry(acc, title, inspect(reason), slug, "error")
@@ -981,6 +1058,9 @@ defmodule BDS.Desktop.ShellLive do
socket
else
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
|> assign(:page_language, normalized)
@@ -1079,6 +1159,34 @@ defmodule BDS.Desktop.ShellLive do
socket
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
append_output_entry(
socket,

View File

@@ -266,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.Bridges do
socket
|> 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(
:status,
ShellData.status_bar(

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
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
main_language = metadata.main_language || language
blog_languages = metadata.blog_languages || []
translate_targets =
[main_language | blog_languages]
|> Enum.reject(&(&1 == language or is_nil(&1)))
|> Enum.uniq()
translate_targets = translate_targets(metadata, language)
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
tasks =
@@ -48,6 +41,36 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
send(parent, {:add_images_complete, length(paths)})
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(
[],
tasks,

View File

@@ -457,7 +457,7 @@
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
<%= for tab <- @panel_tabs do %>
<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"
phx-click="select_panel_tab"
phx-value-tab={tab}

View File

@@ -20,6 +20,17 @@ defmodule BDS.Desktop.ShellLive.Notify do
:ok
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})

View File

@@ -155,7 +155,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
ShellOverlayComponents.markdown_link(result.title, result.canonical_url)}
)
socket
assign(socket, :shell_overlay, nil)
end
{%{kind: :insert_media}, %{type: :post, id: post_id}} ->
@@ -172,7 +172,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
end
Notify.parent({:post_editor_insert_content, post_id, syntax})
socket
assign(socket, :shell_overlay, nil)
end
_other ->
@@ -197,10 +197,11 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
if details do
Notify.parent({:post_editor_insert_content, post_id, details})
assign(socket, :shell_overlay, nil)
else
socket
end
socket
_other ->
socket
end

View File

@@ -10,6 +10,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
alias BDS.PostLinks
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.UI.TaskGrouping
use Gettext, backend: BDS.Gettext
@doc "Render the active panel tab body."
@@ -48,34 +49,122 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
end
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"""
<%= 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">
<strong><%= dgettext("ui", "Tasks") %></strong>
<span><%= dgettext("ui", "No background tasks running") %></span>
</div>
<% else %>
<div class="task-list flex flex-col gap-2">
<%= for task <- Map.get(@task_status, :tasks, []) do %>
<div class="panel-entry ui-panel-entry task-entry flex flex-col gap-2">
<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 %>
</div>
<%= for entry <- @task_entries do %>
<%= case entry do %>
<% {:single, task} -> %>
{render_task_row(assigns, task, false)}
<% {:group, group_id, group_name, tasks} -> %>
{render_task_group(assigns, group_id, group_name, tasks)}
<% end %>
<% end %>
</div>
<% 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
~H"""
<%= if Enum.empty?(@output_entries) do %>
@@ -84,6 +173,16 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
<span><%= dgettext("ui", "No shell output yet") %></span>
</div>
<% 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">
<%= for entry <- @output_entries do %>
<div class={[

View File

@@ -6,11 +6,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
alias BDS.{AI, Embeddings, Metadata, Posts, Preview, Tasks}
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{EditorImageDrop, Notify}
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata}
alias BDS.Posts.Post
alias BDS.Tags
alias BDS.UI.PostEditor.{Draft, ListValues, Persistence}
import DraftManagement,
import Draft,
only: [
editing_canonical_language?: 3,
normalize_language: 2,
@@ -48,7 +48,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
use Gettext, backend: BDS.Gettext
import PostMetadata,
import BDS.UI.PostEditor.Metadata,
only: [
blank?: 1,
blank_to_nil: 1,
@@ -117,7 +117,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
id: socket.assigns.post_id,
content: content
})
|> assign(:shell_overlay, nil)
{:ok, socket}
end
@@ -316,18 +315,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
{:noreply, do_remove_list_value(socket, :categories, category)}
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
socket =
socket
@@ -892,7 +879,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end)
if map_size(attrs) == 0 do
assign(socket, :shell_overlay, nil)
socket
else
case Posts.update_post(post_id, attrs) do
{:ok, updated_post} ->
@@ -910,7 +897,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
)
|> assign(:save_state, :dirty)
|> assign(:dirty?, true)
|> assign(:shell_overlay, nil)
|> build_data()
Notify.dirty(:post, post_id, true)

View File

@@ -1,241 +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), do: BDS.Values.truthy?(value)
defp blank_to_nil(value) do
value
|> to_string()
|> String.trim()
|> case do
"" -> nil
trimmed -> trimmed
end
end
end

View File

@@ -206,11 +206,17 @@ defmodule BDS.Desktop.ShellLive.ScriptEditor do
%Script{} = 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(
script.project_id,
draft["content"] || "",
draft["entrypoint"] || "main",
[]
[],
on_output: fn text -> Notify.output_to(live_view, title, text, nil, "info") end
) do
{:ok, result} ->
notify_output(socket, dgettext("ui", "Scripts"), inspect(result))

View File

@@ -69,6 +69,15 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
{: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
socket =
socket
@@ -302,7 +311,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
query,
~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,
protected_categories: ManagedCategories.protected_categories()
}

View File

@@ -107,6 +107,24 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp do_save_ai(socket, reload, append_output) do
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 <-
put_endpoint_preferences(
:online,
@@ -156,15 +174,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
attrs.offline_chat_images
),
:ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do
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)
else
{:error, reason} ->
surface_ai_error(socket, reload, append_output, reason)
:ok
end
end

View File

@@ -381,6 +381,15 @@
<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>
</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>
<% end %>
</div>

View File

@@ -38,7 +38,7 @@ defmodule BDS.Desktop.ShellLive.SocketState do
|> 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(:editor_meta, ShellData.editor_meta(task_status, page_language))
|> assign(
:status,
ShellData.status_bar(workbench, task_status, dashboard,

View File

@@ -7,7 +7,6 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
alias BDS.{Repo, Tags}
alias BDS.Desktop.ShellLive.Notify
alias BDS.Posts.Post
alias BDS.Tags.Tag
alias BDS.Templates.Template
use Gettext, backend: BDS.Gettext
@@ -16,12 +15,8 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
@tags_sections ~w(cloud manage merge)
@colour_presets ~w(
#ef4444 #f97316 #f59e0b #eab308 #84cc16
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
#ec4899 #64748b
)
# Shared with the TUI tags panel (issue #34) via BDS.UI.TagsPanel.
@colour_presets BDS.UI.TagsPanel.colour_presets()
@spec colour_presets() :: [String.t()]
def colour_presets, do: @colour_presets
@@ -439,12 +434,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
defp current_tab_meta(_assigns), do: %{}
defp tag_counts(project_id) do
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 tag_counts(project_id), do: BDS.UI.TagsPanel.tag_counts(project_id)
defp blank_to_nil(nil), do: nil

View File

@@ -32,6 +32,11 @@ defmodule BDS.Embeddings.Backends.Neural do
* Everywhere else — and as a fallback when EMLX is unavailable or explicitly
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:`
to `:auto` (default), `:emlx`, or `:exla`.
"""
@@ -171,7 +176,20 @@ defmodule BDS.Embeddings.Backends.Neural do
@doc false
@spec current_accelerator() :: :emlx | :exla
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
@doc """
@@ -179,22 +197,43 @@ defmodule BDS.Embeddings.Backends.Neural do
Prefer the Apple GPU (EMLX) under `:auto` only when it is both available and
running on Apple Silicon; honour an explicit `:emlx`/`:exla` request, but
degrade a forced `:emlx` to EXLA when EMLX is not loaded so a misconfigured
host still gets working CPU inference instead of crashing.
degrade to the other backend when the requested one is not available so a
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
def select_accelerator(:exla, _emlx_available?, _apple_silicon?), do: :exla
def select_accelerator(:emlx, true, _apple_silicon?), do: :emlx
def select_accelerator(:emlx, false, _apple_silicon?), do: :exla
def select_accelerator(:auto, true, true), do: :emlx
def select_accelerator(:auto, _emlx_available?, _apple_silicon?), do: :exla
@spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean(), boolean()) ::
:emlx | :exla | :none
def select_accelerator(_configured, false, false, _apple_silicon?), do: :none
def select_accelerator(:exla, _emlx?, true, _apple_silicon?), do: :exla
def select_accelerator(:exla, true, false, _apple_silicon?), do: :emlx
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
config() |> Keyword.get(:accelerator, @default_accelerator)
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
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
defp apple_silicon? 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

View File

@@ -178,7 +178,7 @@ defmodule BDS.Generation do
build_core_outputs(
plan,
data.published_list_posts,
render_data.main_list_posts,
render_data.localized_list_posts_by_language,
on_output
) ++
@@ -274,7 +274,7 @@ defmodule BDS.Generation do
end
defp full_localized_archive_outputs(plan, render_data, builder) do
main_outputs = builder.(plan, render_data.data.post_index, [plan.language])
main_outputs = builder.(plan, render_data.main_post_index, [plan.language])
language_outputs =
Enum.flat_map(additional_languages(plan), fn language ->
@@ -286,11 +286,8 @@ defmodule BDS.Generation do
end
defp core_sitemap_outputs(plan, data) do
published_route_posts =
suppress_subtree_translation_variants(data.published_route_posts, additional_languages(plan))
{_sitemap_content, sitemap_to_write, _expected_paths, _timestamp_checks} =
build_validation_sitemap_artifacts(plan, data, published_route_posts, %{}, nil)
build_validation_sitemap_artifacts(plan, data, data.published_posts, %{}, nil)
[{"sitemap.xml", sitemap_to_write}]
end
@@ -321,17 +318,13 @@ defmodule BDS.Generation do
{:ok, generated_files_list} = list_generated_files(project_id)
generated_file_updated_at = generated_file_updated_at_map(generated_files_list)
additional_languages = additional_languages(plan)
published_route_posts =
suppress_subtree_translation_variants(data.published_route_posts, additional_languages)
{sitemap_content, sitemap_to_write, additional_expected_paths,
additional_post_timestamp_checks} =
build_validation_sitemap_artifacts(
plan,
data,
published_route_posts,
data.published_posts,
generated_file_updated_at,
on_progress
)
@@ -350,7 +343,7 @@ defmodule BDS.Generation do
post_timestamp_checks:
build_post_timestamp_checks(
data.project_data_dir,
published_route_posts,
data.published_posts,
generated_file_updated_at
) ++ additional_post_timestamp_checks,
additional_expected_paths: additional_expected_paths
@@ -647,53 +640,15 @@ defmodule BDS.Generation do
defp build_outputs(plan, on_output \\ nil) do
plan = with_render_context(plan)
data = generation_data(plan)
published_translations = flattened_generation_translations(data.translations_by_post)
translations_by_post_language = translation_lookup_map(published_translations)
translatable_published_posts =
Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
translatable_published_list_posts =
Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
localized_posts_by_language =
additional_languages(plan)
|> Enum.map(fn language ->
{language,
resolve_posts_for_language(
translatable_published_posts,
language,
translations_by_post_language,
plan.language
)}
end)
|> Map.new()
localized_list_posts_by_language =
additional_languages(plan)
|> Enum.map(fn language ->
{language,
resolve_posts_for_language(
translatable_published_list_posts,
language,
translations_by_post_language,
plan.language
)}
end)
|> Map.new()
localized_post_indexes =
localized_list_posts_by_language
|> Enum.map(fn {language, posts} -> {language, build_generation_post_index(posts)} end)
|> Map.new()
render_data = validation_render_data(plan)
data = render_data.data
core_outputs =
if :core in plan.sections do
build_core_outputs(
plan,
data.published_list_posts,
localized_list_posts_by_language,
render_data.main_list_posts,
render_data.localized_list_posts_by_language,
on_output
)
else
@@ -706,8 +661,8 @@ defmodule BDS.Generation do
plan_render_target(plan),
plan.language,
data.published_posts,
translations_by_post_language,
localized_posts_by_language,
render_data.translations_by_post_language,
render_data.localized_posts_by_language,
on_output
)
else
@@ -720,8 +675,8 @@ defmodule BDS.Generation do
plan_render_target(plan),
plan.language,
data.published_posts,
translations_by_post_language,
localized_posts_by_language,
render_data.translations_by_post_language,
render_data.localized_posts_by_language,
on_output
)
else
@@ -729,7 +684,12 @@ defmodule BDS.Generation do
end
archive_outputs =
build_archive_outputs(plan, data.post_index, localized_post_indexes, on_output)
build_archive_outputs(
plan,
render_data.main_post_index,
render_data.localized_post_indexes,
on_output
)
urls =
(core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs)
@@ -770,14 +730,14 @@ defmodule BDS.Generation do
defp build_validation_sitemap_artifacts(
plan,
data,
published_route_posts,
published_posts,
generated_file_updated_at,
on_progress
) do
main_paths =
build_validation_route_paths(
plan,
published_route_posts,
published_posts,
data.published_list_posts,
data.post_index,
nil
@@ -1111,12 +1071,26 @@ defmodule BDS.Generation do
{language, build_generation_post_index(posts)}
end)
# Main-language lists must show the main-language translation of a
# foreign-language post when one exists, mirroring the canonical variant
# lookup single-page renders do. Resolved here (not in generation_data)
# because subtree resolution above needs the raw posts' ids intact.
main_list_posts =
resolve_posts_for_language(
data.published_list_posts,
plan.language,
translations_by_post_language,
plan.language
)
%{
data: data,
translations_by_post_language: translations_by_post_language,
localized_posts_by_language: localized_posts_by_language,
localized_list_posts_by_language: localized_list_posts_by_language,
localized_post_indexes: localized_post_indexes
localized_post_indexes: localized_post_indexes,
main_list_posts: main_list_posts,
main_post_index: build_generation_post_index(main_list_posts)
}
end
@@ -1125,7 +1099,7 @@ defmodule BDS.Generation do
main_root =
if targeted_plan.request_root_routes do
posts = build_list_posts(plan, data.published_list_posts, nil)
posts = build_list_posts(plan, render_data.main_list_posts, nil)
build_root_outputs(plan, plan.language, posts)
else
[]
@@ -1228,7 +1202,7 @@ defmodule BDS.Generation do
end
defp build_targeted_archive_outputs(plan, render_data, targeted_plan, builder) do
main_outputs = builder.(plan, render_data.data.post_index, targeted_plan, [plan.language])
main_outputs = builder.(plan, render_data.main_post_index, targeted_plan, [plan.language])
language_outputs =
Enum.flat_map(additional_languages(plan), fn language ->

View File

@@ -74,8 +74,8 @@ defmodule BDS.Generation.Data do
|> Enum.uniq_by(& &1.id)
|> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)})
{published_route_posts, translations_by_post} =
build_generation_route_posts(
translations_by_post =
build_generation_translations(
plan.project_id,
project_data_dir,
published_posts,
@@ -87,7 +87,6 @@ defmodule BDS.Generation.Data do
project_data_dir: project_data_dir,
published_posts: published_posts,
published_list_posts: published_list_posts,
published_route_posts: published_route_posts,
translations_by_post: translations_by_post,
post_index: build_generation_post_index(published_list_posts)
}
@@ -121,18 +120,13 @@ defmodule BDS.Generation.Data do
post_language = String.downcase(to_string(post.language || ""))
effective_language = if post_language == "", do: main, else: post_language
cond do
is_binary(Map.get(post, :translation_source_slug)) ->
post
effective_language == target ->
post
true ->
case Map.get(translations_by_post_language, {post.id, target_language}) do
nil -> post
translation -> build_localized_subtree_variant(post, translation)
end
if effective_language == target do
post
else
case Map.get(translations_by_post_language, {post.id, target_language}) do
nil -> post
translation -> build_localized_subtree_variant(post, translation)
end
end
end)
end
@@ -231,7 +225,7 @@ defmodule BDS.Generation.Data do
read_snapshot(full_path, fallback_post, &build_post_snapshot/2)
end
defp build_generation_route_posts(
defp build_generation_translations(
project_id,
project_data_dir,
published_posts,
@@ -268,17 +262,7 @@ defmodule BDS.Generation.Data do
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
|> Map.get(post.id, [])
|> Enum.map(&build_published_translation_variant(post, &1))
[post | variants]
end)
{route_posts, translations_by_post}
translations_by_post
end
defp published_translation_snapshot(project_data_dir, %Translation{} = translation) do
@@ -357,31 +341,6 @@ defmodule BDS.Generation.Data do
}
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
%{
post

View File

@@ -22,16 +22,6 @@ defmodule BDS.Generation.Outputs do
def route_post_output_path(post, ""), do: post_output_path(post)
def route_post_output_path(post, route_language), do: post_output_path(post, route_language)
@spec suppress_subtree_translation_variants([map()], [String.t()]) :: [map()]
def suppress_subtree_translation_variants(route_posts, additional_languages) do
subtree_languages = MapSet.new(additional_languages)
Enum.reject(route_posts, fn post ->
is_binary(Map.get(post, :translation_source_slug)) and
MapSet.member?(subtree_languages, to_string(post.language))
end)
end
@spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [
String.t()
]

View File

@@ -28,15 +28,15 @@ defmodule BDS.Generation.Validation do
@spec build_post_timestamp_checks(String.t(), [map()], map()) :: [map()]
def build_post_timestamp_checks(
project_data_dir,
published_route_posts,
published_posts,
generated_file_updated_at
) do
build_timestamp_checks(
project_data_dir,
published_route_posts,
published_posts,
generated_file_updated_at,
&BDS.Generation.Paths.post_output_path/1,
fn post -> Map.get(post, :translation_file_path) || post.file_path end
& &1.file_path
)
end

View File

@@ -62,10 +62,23 @@ defmodule BDS.I18n do
def resolve_ui_locale(locale), do: resolve_supported_locale(locale) || @default_language
def current_ui_locale do
(System.get_env("LC_ALL") || System.get_env("LC_MESSAGES") || System.get_env("LANG"))
(stored_ui_locale() || System.get_env("LC_ALL") || System.get_env("LC_MESSAGES") ||
System.get_env("LANG"))
|> resolve_ui_locale()
end
# The UI language is a server-side setting shared by all connected clients
# (issue #26); the OS locale is only the fallback for a fresh install.
defp stored_ui_locale do
with true <- BDS.Repo.ready?(),
value when is_binary(value) and value != "" <-
BDS.Settings.get_global_setting("ui.language") do
value
else
_other -> nil
end
end
def resolve_render_locale(language), do: resolve_supported_locale(language) || @default_language
def format_locale(language) do

View File

@@ -70,17 +70,20 @@ defmodule BDS.MacBundle do
frameworks = Path.join(contents, "Frameworks")
rel = Path.join(resources, "rel")
File.rm_rf!(app)
Enum.each([macos, resources, frameworks], &File.mkdir_p!/1)
# Validate before rm_rf! so a refused build leaves any previous bundle intact.
with :ok <- BDS.ReleasePackaging.validate_release_source(release_dir) do
File.rm_rf!(app)
Enum.each([macos, resources, frameworks], &File.mkdir_p!/1)
with {:ok, _} <- File.cp_r(release_dir, rel),
:ok <- File.cp(icns_path, Path.join(resources, "#{@icon_name}.icns")),
:ok <- File.write(Path.join(contents, "Info.plist"), info_plist(version: version)),
:ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"),
:ok <- write_launcher(Path.join(macos, @executable)),
:ok <- maybe_relocate_dylibs(rel, frameworks, opts),
:ok <- maybe_codesign(app, opts) do
{:ok, app}
with {:ok, _} <- File.cp_r(release_dir, rel),
:ok <- File.cp(icns_path, Path.join(resources, "#{@icon_name}.icns")),
:ok <- File.write(Path.join(contents, "Info.plist"), info_plist(version: version)),
:ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"),
:ok <- write_launcher(Path.join(macos, @executable)),
:ok <- maybe_relocate_dylibs(rel, frameworks, opts),
:ok <- maybe_codesign(app, opts) do
{:ok, app}
end
end
end
@@ -130,8 +133,10 @@ defmodule BDS.MacBundle do
@doc """
Fail the build unless the bundle is self-contained: no Mach-O under the `.app`
may reference a Homebrew/local (`/opt/homebrew`, `/usr/local`) path. This is a
hard requirement — the app must run on a Mac without Homebrew installed.
may reference a Homebrew/local (`/opt/homebrew`, `/usr/local`) path, and every
`@rpath/` dependency must resolve — via the binary's own `LC_RPATH`s — to a
file inside the bundle. This is a hard requirement — the app must run on a
Mac without Homebrew installed.
"""
@spec verify_standalone(String.t()) ::
:ok | {:error, {:external_refs, [{String.t(), String.t()}]}}
@@ -141,7 +146,8 @@ defmodule BDS.MacBundle do
|> macho_files()
|> Enum.flat_map(fn file ->
external_refs(file, ["-L"], &Dylibs.parse_otool/1) ++
external_refs(file, ["-l"], &Dylibs.parse_rpaths/1)
external_refs(file, ["-l"], &Dylibs.parse_rpaths/1) ++
unresolved_rpath_refs(file, app)
end)
if offenders == [], do: :ok, else: {:error, {:external_refs, offenders}}
@@ -154,6 +160,50 @@ defmodule BDS.MacBundle do
end
end
# An @rpath/ dep is an offender unless one of its rpath-resolved candidates
# exists inside the app — a dangling one (Homebrew's libwebp referencing
# @rpath/libsharpyuv.0.dylib with no copied sibling) crashes only at runtime
# on the user's machine, so it must fail the build here. A dylib's own
# LC_ID_DYLIB shows up in `otool -L` like a dep (precompiled NIFs such as
# hnswlib_nif.so ship an @rpath/ self id with no rpaths at all); dyld never
# resolves a file's id when loading that file, so self references are skipped.
defp unresolved_rpath_refs(file, app) do
root = Path.expand(app)
case otool_parse(file, ["-L"], &Dylibs.parse_otool/1) do
{:ok, deps} ->
deps
|> Enum.filter(&String.starts_with?(&1, "@rpath/"))
|> Enum.reject(&(Path.basename(&1) == Path.basename(file)))
|> Enum.reject(&rpath_dep_resolves?(file, &1, root))
|> Enum.map(&{file, &1})
_error ->
[]
end
end
defp rpath_dep_resolves?(file, dep, root) do
case otool_parse(file, ["-l"], &Dylibs.parse_rpaths/1) do
{:ok, rpaths} ->
dep
|> Dylibs.resolve_rpath_dep(rpaths, Path.dirname(file))
|> Enum.any?(fn candidate ->
String.starts_with?(candidate, root <> "/") and File.exists?(candidate)
end)
_error ->
false
end
end
defp otool_parse(file, args, parse) do
case System.cmd("otool", args ++ [file], stderr_to_stdout: true) do
{out, 0} -> {:ok, parse.(out)}
{out, status} -> {:error, {:otool_failed, status, out}}
end
end
@doc """
Compute a `../`-style relative path from `from_dir` to `to` (both absolute),
e.g. the path from the wx NIF's directory up to `Contents/Frameworks`.

View File

@@ -103,16 +103,43 @@ defmodule BDS.MacBundle.Dylibs do
end
@doc """
Build `{old, new}` rewrites for a binary's `otool -L` dependency list: every
external dep becomes `<prefix>/<basename>`. System (`/usr/lib`, `/System`) and
already-relocatable (`@rpath`/`@loader_path`/`@executable_path`) deps are
dropped. Rewriting by basename — not by the exact source path — is what makes
this catch every absolute spelling of the same library.
Resolve an `@rpath/<name>` dependency to absolute candidate paths using the
referencing binary's `LC_RPATH` list, the way dyld would: each rpath entry is
tried in order with `@loader_path` expanded to the binary's own directory.
`@executable_path` rpaths are skipped (not resolvable from a library), and
non-`@rpath` deps yield no candidates. Homebrew links keg siblings this way
(`@rpath/libsharpyuv.0.dylib` + rpath `@loader_path/../lib` in libwebp), so
these deps are just as external as absolute `/opt/homebrew` ones.
"""
@spec relink_changes([String.t()], String.t()) :: [{String.t(), String.t()}]
def relink_changes(deps, prefix) when is_list(deps) do
@spec resolve_rpath_dep(String.t(), [String.t()], String.t()) :: [String.t()]
def resolve_rpath_dep("@rpath/" <> name, rpaths, loader_dir) when is_list(rpaths) do
rpaths
|> Enum.map(fn
"@loader_path" <> rest -> Path.expand(loader_dir <> rest)
"@executable_path" <> _rest -> nil
absolute -> absolute
end)
|> Enum.reject(&is_nil/1)
|> Enum.map(&Path.join(&1, name))
end
def resolve_rpath_dep(_dep, _rpaths, _loader_dir), do: []
@doc """
Build `{old, new}` rewrites for a binary's `otool -L` dependency list: every
external dep becomes `<prefix>/<basename>`, and every `@rpath/<name>` dep
whose basename is in `bundled` (it was copied into Frameworks) is rewritten
the same way. System (`/usr/lib`, `/System`) and other already-relocatable
deps are dropped. Rewriting by basename — not by the exact source path — is
what makes this catch every absolute spelling of the same library.
"""
@spec relink_changes([String.t()], String.t(), [String.t()]) :: [{String.t(), String.t()}]
def relink_changes(deps, prefix, bundled \\ []) when is_list(deps) do
deps
|> Enum.filter(&external?/1)
|> Enum.filter(fn
"@rpath/" <> name -> name in bundled
dep -> external?(dep)
end)
|> Enum.map(fn dep -> {dep, prefix <> "/" <> Path.basename(dep)} end)
end
@@ -135,9 +162,10 @@ defmodule BDS.MacBundle.Dylibs do
with {:ok, externals} <- collect(nifs, %{}) do
reals = materialize(externals, frameworks_dir)
bundled = Map.keys(externals)
with :ok <- relink_each(reals, fn _real -> "@loader_path" end, set_id: true),
:ok <- relink_each(nifs, prefix_for, set_id: false) do
with :ok <- relink_each(reals, fn _real -> "@loader_path" end, bundled, set_id: true),
:ok <- relink_each(nifs, prefix_for, bundled, set_id: false) do
{:ok, reals}
end
end
@@ -147,10 +175,9 @@ defmodule BDS.MacBundle.Dylibs do
# basename — different absolute spellings of the same leaf collapse here).
defp collect(binaries, acc) do
Enum.reduce_while(binaries, {:ok, acc}, fn bin, {:ok, acc} ->
case otool(bin) do
case external_deps(bin) do
{:ok, deps} ->
deps
|> Enum.filter(&external?/1)
|> Enum.reduce_while({:ok, acc}, fn dep, {:ok, acc} ->
base = Path.basename(dep)
@@ -174,6 +201,42 @@ defmodule BDS.MacBundle.Dylibs do
end)
end
# A binary's external deps: absolute Homebrew/local references, plus any
# @rpath/ reference that resolves (via the binary's own LC_RPATHs) to an
# existing Homebrew/local file. @rpath deps resolving inside the release tree
# (vix/exla-style precompiled NIFs) are relocatable as-is and stay untouched.
defp external_deps(bin) do
with {:ok, deps} <- otool(bin) do
direct = Enum.filter(deps, &external?/1)
case resolve_external_rpath_deps(bin, deps) do
{:ok, resolved} -> {:ok, direct ++ resolved}
error -> error
end
end
end
defp resolve_external_rpath_deps(bin, deps) do
case Enum.filter(deps, &String.starts_with?(&1, "@rpath/")) do
[] ->
{:ok, []}
rpath_deps ->
with {:ok, search_paths} <- rpaths(bin) do
resolved =
rpath_deps
|> Enum.map(fn dep ->
dep
|> resolve_rpath_dep(search_paths, Path.dirname(bin))
|> Enum.find(fn candidate -> external?(candidate) and File.exists?(candidate) end)
end)
|> Enum.reject(&is_nil/1)
{:ok, resolved}
end
end
end
# Copy each external once (deduped by device+inode, which follows symlinks to
# the real file); additional basenames for the same file become symlinks so
# dyld loads a single image. Returns the real (non-symlink) copies.
@@ -206,12 +269,12 @@ defmodule BDS.MacBundle.Dylibs do
{stat.major_device, stat.inode}
end
defp relink_each(binaries, prefix_for, opts) do
defp relink_each(binaries, prefix_for, bundled, opts) do
set_id? = Keyword.fetch!(opts, :set_id)
Enum.reduce_while(binaries, :ok, fn binary, :ok ->
with :ok <- maybe_set_id(binary, set_id?),
:ok <- relink(binary, prefix_for.(binary)) do
:ok <- relink(binary, prefix_for.(binary), bundled) do
{:cont, :ok}
else
error -> {:halt, error}
@@ -225,9 +288,9 @@ defmodule BDS.MacBundle.Dylibs do
run("install_name_tool", id_args(binary, "@loader_path/" <> Path.basename(binary)))
end
defp relink(binary, prefix) do
defp relink(binary, prefix, bundled) do
with {:ok, deps} <- otool(binary),
:ok <- apply_changes(binary, change_args(binary, relink_changes(deps, prefix))) do
:ok <- apply_changes(binary, change_args(binary, relink_changes(deps, prefix, bundled))) do
strip_external_rpaths(binary)
end
end

View File

@@ -142,4 +142,106 @@ defmodule BDS.Maintenance do
{:ok, %{diff_reports: diff_reports, orphan_reports: orphan_reports}}
end
@doc """
The canonical full-rebuild sequence, shared by the GUI "Rebuild Database"
command and the CLI `rebuild` command so the two can never drift. Each step
is `%{name: String.t(), work: (report -> result)}` where `report` is a
2-arity progress callback and a `{:error, message}` result marks failure.
"""
def full_rebuild_steps(project_id) when is_binary(project_id) do
[
%{
name: "Rebuild Posts From Files",
work: fn report ->
{:ok, posts} =
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} = 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} = 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} = 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 = BDS.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(project_id, on_progress: report) end
}
]
end
@doc """
Rebuilds the embedding index for the project, mapping backend failures to a
user-facing message (`{:error, message}`).
"""
def rebuild_embedding_index(project_id, opts \\ []) when is_binary(project_id) do
on_progress = progress_callback(opts)
case Embeddings.rebuild_project(project_id, on_progress: on_progress) do
{:ok, rebuilt_post_ids} ->
on_progress.(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
end

View File

@@ -34,6 +34,7 @@ defmodule BDS.Media do
import Ecto.Query
alias BDS.Events
alias BDS.Media.Media
alias BDS.Media.Translation
alias BDS.Persistence
@@ -121,6 +122,7 @@ defmodule BDS.Media do
log_sidecar_error(write_sidecar(project, media), media.id)
log_thumbnail_error(ensure_thumbnails(project, media), media.id)
:ok = Search.sync_media(media)
:ok = Events.entity_changed("media", media.id, :created)
{:ok, media}
{:error, reason} ->
@@ -162,6 +164,7 @@ defmodule BDS.Media do
{:ok, updated_media} ->
log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
:ok = Search.sync_media(updated_media)
:ok = Events.entity_changed("media", updated_media.id, :updated)
{:ok, updated_media}
{:error, reason} ->
@@ -211,6 +214,7 @@ defmodule BDS.Media do
end)
Search.delete_media(media.id)
:ok = Events.entity_changed("media", media.id, :deleted)
{:ok, :deleted}
{:error, reason} ->

View File

@@ -49,6 +49,11 @@ defmodule BDS.Metadata do
@typedoc "Attribute map for `update_project_metadata/2`."
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@spec supported_pico_themes() :: [String.t()]
def supported_pico_themes do
["default" | @supported_pico_themes |> MapSet.delete("default") |> Enum.sort()]
end
@spec get_project_metadata(String.t()) :: {:ok, metadata_state()}
def get_project_metadata(project_id) do
project = Projects.get_project!(project_id)
@@ -313,6 +318,8 @@ defmodule BDS.Metadata do
}
end
# Keys absent from attrs are left out entirely, so the merge in
# update_project_metadata/2 keeps their current values (partial update).
defp normalize_project_metadata_attrs(attrs, project) do
%{
name: attr(attrs, :name) || project.name,
@@ -328,6 +335,26 @@ defmodule BDS.Metadata do
semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false,
blog_languages: normalize_language_list(attr(attrs, :blog_languages) || [])
}
|> Map.take(present_attr_keys(attrs))
end
defp present_attr_keys(attrs) do
Enum.filter(
[
:name,
:description,
:public_url,
:main_language,
:default_author,
:max_posts_per_page,
:image_import_concurrency,
:blogmark_category,
:pico_theme,
:semantic_similarity_enabled,
:blog_languages
],
fn key -> Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) end
)
end
defp normalize_category_settings(settings) do

View File

@@ -12,6 +12,7 @@ defmodule BDS.Posts do
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
alias BDS.Embeddings
alias BDS.Events
alias BDS.Media
alias BDS.Persistence
alias BDS.PostLinks
@@ -106,6 +107,7 @@ defmodule BDS.Posts do
{:ok, post} ->
:ok = Embeddings.sync_post(post)
:ok = Search.sync_post(post)
:ok = Events.entity_changed("post", post.id, :created)
{:ok, post}
error ->
@@ -151,6 +153,7 @@ defmodule BDS.Posts do
:ok = AutoTranslation.maybe_schedule(updated_post)
end
:ok = Events.entity_changed("post", updated_post.id, :updated)
{:ok, updated_post}
error ->
@@ -202,6 +205,7 @@ defmodule BDS.Posts do
:ok = Translations.publish_post_translations(updated_post)
:ok = PostLinks.sync_post_links(updated_post)
:ok = Search.sync_post(updated_post)
:ok = Events.entity_changed("post", updated_post.id, :updated)
{:ok, updated_post}
error ->
@@ -335,6 +339,7 @@ defmodule BDS.Posts do
PostLinks.delete_post_links(deleted_post.id)
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
Search.delete_post(deleted_post.id)
:ok = Events.entity_changed("post", deleted_post.id, :deleted)
{:ok, :deleted}
{:error, changeset} ->

View File

@@ -56,7 +56,8 @@ defmodule BDS.ReleasePackaging do
app_release_source = Keyword.fetch!(opts, :app_release_source)
mcp_release_source = Keyword.fetch!(opts, :mcp_release_source)
with :ok <- reset_output(metadata),
with :ok <- validate_release_source(app_release_source),
:ok <- reset_output(metadata),
:ok <- copy_release(app_release_source, metadata.app_root),
:ok <- copy_release(mcp_release_source, metadata.resources_root),
:ok <- write_manifest(metadata),
@@ -65,6 +66,20 @@ defmodule BDS.ReleasePackaging do
end
end
@doc """
Rejects half-assembled releases. A failed `mix release` run can leave `lib/`
copied but no `releases/<version>/` (no `env.sh`, no boot scripts) — packaging
that produces an app that dies on launch. Refuse it up front instead.
"""
def validate_release_source(release_dir) do
if release_dir |> Path.join("releases/*/env.sh") |> Path.wildcard() == [] do
{:error,
{:incomplete_release, "#{release_dir} has no releases/*/env.sh — did `mix release` fail?"}}
else
:ok
end
end
defp normalize_platform(platform) when platform in [:macos, :linux, :windows], do: platform
defp normalize_platform(:darwin), do: :macos

View File

@@ -38,7 +38,8 @@ defmodule BDS.Scripting.ApiDocs do
%{
module: "app",
name: "get_system_language",
description: "Return the current UI locale.",
description:
"Return the current UI locale (the server-side UI language setting, falling back to the OS locale when unset).",
params: [],
returns: "string | nil"
},
@@ -49,6 +50,14 @@ defmodule BDS.Scripting.ApiDocs do
params: [],
returns: "table | nil"
},
%{
module: "app",
name: "log",
description:
"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.",
params: [%{name: "text", type: "string", required: true}],
returns: "boolean"
},
%{
module: "app",
name: "notify_renderer_ready",
@@ -761,7 +770,8 @@ defmodule BDS.Scripting.ApiDocs do
%{
module: "meta",
name: "update_project_metadata",
description: "Update metadata for the current project.",
description:
"Update metadata for the current project. Keys omitted from updates keep their current values.",
params: [%{name: "updates", type: "table", required: true}],
returns: "ProjectMetadata | nil"
},

View File

@@ -49,6 +49,7 @@ defmodule BDS.Scripting.Capabilities do
get_system_language: zero_or_one_arg(fn _args -> I18n.current_ui_locale() end),
get_default_project_path: zero_or_one_arg(fn _args -> project_path(project_id) end),
get_title_bar_metrics: zero_or_one_arg(fn _args -> title_bar_metrics(opts) end),
log: BDS.Scripting.Lua.output_function(opts, [true]),
notify_renderer_ready: zero_or_one_arg(fn _args -> notify_renderer_ready(opts) end),
open_folder: one_arg(fn folder_path -> open_folder(folder_path, opts) end),
read_project_metadata: one_arg(fn folder_path -> read_project_metadata(folder_path) end),

View File

@@ -6,16 +6,20 @@ defmodule BDS.Scripting.Lua do
and opt-in.
"""
require Logger
@type source :: String.t()
@type entrypoint :: String.t()
@type args :: [term()]
@type progress_event :: map()
@type progress_callback :: (progress_event() -> any())
@type output_callback :: (String.t() -> any())
@type execution_option ::
{:timeout, non_neg_integer() | :infinity}
| {:max_reductions, pos_integer() | :none}
| {:spawn_opts, [term()]}
| {:on_progress, progress_callback()}
| {:on_output, output_callback()}
| {:capabilities, map()}
@callback validate(source()) :: :ok | {:error, term()}
@@ -48,12 +52,65 @@ defmodule BDS.Scripting.Lua do
end
end
@doc """
Returns the sink script output (`print`, `bds.app.log`) is routed to: the
`:on_output` callback when given, otherwise a Logger-based default so script
output never leaks onto the console (where it would corrupt the TUI).
"""
@spec output_sink(keyword()) :: output_callback()
def output_sink(opts) when is_list(opts) do
case Keyword.get(opts, :on_output) do
callback when is_function(callback, 1) ->
callback
_other ->
fn text -> Logger.debug("script output: #{text}") end
end
end
@doc """
Builds the Lua-callable function shared by the global `print` override and
the `bds.app.log` capability: joins all arguments with spaces and forwards
the line to the output sink.
"""
@spec output_function(keyword(), [term()]) :: (list(), lua_state() -> {list(), lua_state()})
def output_function(opts, return_values \\ []) when is_list(opts) and is_list(return_values) do
sink = output_sink(opts)
fn args, current_state ->
line =
args
|> :luerl.decode_list(current_state)
|> Enum.map_join(" ", &format_lua_value/1)
sink.(line)
:luerl.encode_list(return_values, current_state)
end
end
@doc "Formats a decoded Lua value the way Lua's `tostring` would present it."
@spec format_lua_value(term()) :: String.t()
def format_lua_value(nil), do: "nil"
def format_lua_value(true), do: "true"
def format_lua_value(false), do: "false"
def format_lua_value(value) when is_binary(value), do: value
def format_lua_value(value) when is_float(value) do
if Float.round(value, 0) == value,
do: "#{trunc(value)}.0",
else: to_string(value)
end
def format_lua_value(value) when is_integer(value), do: Integer.to_string(value)
def format_lua_value(value), do: inspect(value)
defp initial_state(opts) do
state = :luerl_sandbox.init()
capabilities = Keyword.get(opts, :capabilities, %{})
with {:ok, state} <- :luerl.set_table_keys_dec(["bds"], %{}, state),
{:ok, state} <- install_progress_callback(state, Keyword.get(opts, :on_progress)),
{:ok, state} <- install_print_override(state, opts),
{:ok, state} <- install_capabilities(state, capabilities) do
{:ok, state}
end
@@ -85,6 +142,15 @@ defmodule BDS.Scripting.Lua do
defp install_progress_callback(_state, callback),
do: {:error, {:invalid_progress_callback, callback}}
# Replaces the built-in `print` (which would write straight to the BEAM
# console) with a function routed to the output sink.
defp install_print_override(state, opts) do
case :luerl.set_table_keys_dec(["print"], output_function(opts), state) do
{:ok, next_state} -> {:ok, next_state}
error -> {:error, {:print_override_install_failed, error}}
end
end
defp install_capabilities(state, capabilities) when capabilities in [%{}, []], do: {:ok, state}
defp install_capabilities(state, capabilities) when is_map(capabilities) do

View File

@@ -5,6 +5,7 @@ defmodule BDS.Scripts do
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
alias BDS.DocumentFields
alias BDS.Events
alias BDS.Frontmatter
alias BDS.Persistence
alias BDS.ProgressReporter
@@ -40,6 +41,7 @@ defmodule BDS.Scripts do
updated_at: now
})
|> Repo.insert()
|> Events.broadcast_result("script", :created)
end
@spec create_and_publish_script(attrs()) :: script_result()
@@ -84,6 +86,7 @@ defmodule BDS.Scripts do
serialize_script_file(script, content)
)
:ok = Events.entity_changed("script", script.id, :created)
{:ok, script}
end
@@ -132,6 +135,7 @@ defmodule BDS.Scripts do
updated_at: updated_at
})
|> Repo.update()
|> Events.broadcast_result("script", :updated)
{:error, reason} ->
{:error, {:invalid_script, reason}}
@@ -176,6 +180,7 @@ defmodule BDS.Scripts do
script
|> Script.changeset(updates)
|> Repo.update()
|> Events.broadcast_result("script", :updated)
end
end
@@ -189,6 +194,7 @@ defmodule BDS.Scripts do
case Repo.delete(script) do
{:ok, deleted_script} ->
delete_file_if_present(deleted_script.project_id, deleted_script.file_path)
:ok = Events.entity_changed("script", deleted_script.id, :deleted)
{:ok, :deleted}
{:error, changeset} ->

221
lib/bds/server.ex Normal file
View File

@@ -0,0 +1,221 @@
defmodule BDS.Server do
@moduledoc """
Headless server mode (issue #26).
Boots the app without any wx window and serves clients over a single SSH
daemon: TUI sessions run server-side via `ExRatatui.SSH` (only terminal
cells cross the wire), and GUI clients tunnel to the loopback HTTP
endpoint through the same daemon (`tcpip_tunnel_out`). Authentication is
public-key only; host key and `authorized_keys` live in the private app
data dir next to the database — never in the repo or a priv dir.
"""
@doc """
Resolves the boot mode from the `BDS_MODE` environment variable:
`"server"` (headless + SSH daemon), `"tui"` (headless + SSH daemon +
a TUI attached to the launching terminal), `"cli"` (headless one-shot
command run — no SSH daemon, no HTTP listener, no watcher; issue #25),
anything else is desktop.
"""
@spec mode(String.t() | nil) :: :desktop | :server | :tui | :cli
def mode(value \\ System.get_env("BDS_MODE"))
def mode(value) when is_binary(value) do
case String.downcase(value) do
"server" -> :server
"tui" -> :tui
"cli" -> :cli
_other -> :desktop
end
end
def mode(nil), do: :desktop
@doc """
The mode the app actually boots in: the resolved `BDS_MODE`, except that
desktop mode falls back to the TUI when no graphical session is available
(issue #33). Starting the app anywhere then still opens a UI — graphical
when possible, the terminal one otherwise.
Per platform: non-Darwin unix needs `DISPLAY` or `WAYLAND_DISPLAY` to run
wx — that check also covers plain ssh sessions, while `ssh -X` with a
forwarded display keeps the GUI. macOS never sets `DISPLAY`, so there an
ssh session (`SSH_CONNECTION`/`SSH_CLIENT`/`SSH_TTY`) is the headless
signal and a local launch always has a graphical session. Windows always
boots the GUI.
"""
@spec effective_mode(:desktop | :server | :tui | :cli, {atom(), atom()}, %{
optional(String.t()) => String.t()
}) :: :desktop | :server | :tui | :cli
def effective_mode(mode \\ mode(), os_type \\ :os.type(), env \\ System.get_env())
def effective_mode(:desktop, {:unix, :darwin}, env) do
if env_any?(env, ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]), do: :tui, else: :desktop
end
def effective_mode(:desktop, {:unix, _os}, env) do
if env_any?(env, ["DISPLAY", "WAYLAND_DISPLAY"]), do: :desktop, else: :tui
end
def effective_mode(mode, _os_type, _env), do: mode
defp env_any?(env, vars), do: Enum.any?(vars, &(env[&1] not in [nil, ""]))
@doc """
Prepares the OS environment for the given effective boot mode and returns
it. The `:desktop` dependency application boots wx inside its own
`Application.start/2` — before any BDS supervisor runs — and that wx load
crashes the whole VM on a display-less system. For every non-desktop mode
this sets `NO_WX=1`, elixir-desktop's own switch that turns all of its wx
calls into no-ops, so the dependency starts inert and the TUI fallback
(issue #33) can actually boot.
In tui mode the app additionally owns the launching terminal, so every
other terminal writer is silenced: the default (console) logger handler
is replaced with a rotating file handler at `tui_log_file/0`, and
Bumblebee's model-download progress bar (raw stdout writes) is disabled.
Each stray line would otherwise scroll the TUI's alternate screen up one
row and corrupt the rendering. Desktop and server modes keep logging to
the terminal, and SSH-served TUI sessions are unaffected either way —
their cells travel over the SSH channel, away from the server console.
Called from `config/runtime.exs`: runtime config is the only hook that
runs before dependency applications start, in both `mix run` and releases.
"""
@spec prepare_boot_env(:desktop | :server | :tui | :cli, (-> :ok)) ::
:desktop | :server | :tui | :cli
def prepare_boot_env(mode \\ effective_mode(), redirect_logs \\ &redirect_logging_to_file/0) do
if mode != :desktop, do: System.put_env("NO_WX", "1")
# CLI mode owns stdout for command output the same way the local TUI owns
# the terminal: console logging goes to the rotating file instead.
if mode in [:tui, :cli] do
Application.put_env(:bumblebee, :progress_bar_enabled, false)
redirect_logs.()
end
mode
end
@doc """
Log file that replaces console logging in local tui mode: a
machine-specific, regenerable artifact, so it lives in the private app
dir (PrivateArtifactsLiveInOsAppDir). Overridable via `:bds, :tui_log_file`.
"""
@spec tui_log_file() :: Path.t()
def tui_log_file do
Application.get_env(:bds, :tui_log_file) ||
Path.join([BDS.Projects.private_dir(), "logs", "bds.log"])
end
@doc """
Replaces the default (console) logger handler with a rotating file
handler writing to `tui_log_file/0`, keeping the configured level,
filters, and Elixir formatter. See `prepare_boot_env/2`.
"""
@spec redirect_logging_to_file() :: :ok
def redirect_logging_to_file do
log_file = tui_log_file()
File.mkdir_p!(Path.dirname(log_file))
with {:ok, handler} <- :logger.get_handler_config(:default) do
:ok = :logger.remove_handler(:default)
:ok =
:logger.add_handler(
:default,
:logger_std_h,
handler
|> Map.take([:level, :filters, :filter_default, :formatter])
|> Map.put(:config, %{
file: String.to_charlist(log_file),
max_no_bytes: 10_000_000,
max_no_files: 5
})
)
end
:ok
end
@doc """
Whether the HTTP endpoint requires the desktop webview auth token. Only
desktop mode does: in server/tui mode the endpoint stays loopback-only
and clients arrive through the key-authenticated SSH tunnel, which the
per-boot webview token would otherwise lock out.
"""
@spec desktop_auth_required?(:desktop | :server | :tui | :cli) :: boolean()
def desktop_auth_required?(mode \\ effective_mode())
def desktop_auth_required?(:desktop), do: true
def desktop_auth_required?(_mode), do: false
@doc "SSH key-material directory: `<data dir>/ssh` next to the database."
@spec ssh_dir() :: Path.t()
def ssh_dir do
Application.get_env(:bds, BDS.Repo)[:database]
|> Path.dirname()
|> Path.join("ssh")
end
@doc """
Ensures the SSH directory exists with an `authorized_keys` file (created
empty and chmod 600 on first boot; existing keys are never touched).
"""
@spec ensure_ssh_dir!(Path.t()) :: Path.t()
def ensure_ssh_dir!(dir) do
File.mkdir_p!(dir)
keys_path = Path.join(dir, "authorized_keys")
unless File.exists?(keys_path) do
File.write!(keys_path, "")
File.chmod!(keys_path, 0o600)
end
dir
end
@doc """
Options for `ExRatatui.SSH.Daemon`: serves `BDS.TUI`, public-key auth
only, and allows TCP/IP tunneling out so GUI clients can reach the
loopback HTTP endpoint through the SSH connection.
"""
@spec daemon_opts(Path.t()) :: keyword()
def daemon_opts(dir \\ ssh_dir()) do
dir = ensure_ssh_dir!(dir)
system_dir = ExRatatui.SSH.Daemon.ensure_host_key!(dir)
[
mod: BDS.TUI,
port: ssh_port(),
system_dir: system_dir,
user_dir: String.to_charlist(dir),
auth_methods: ~c"publickey",
tcpip_tunnel_out: true
]
end
@doc "SSH listen port: `BDS_SSH_PORT` env or `:bds, :server` config."
@spec ssh_port() :: pos_integer()
def ssh_port do
case System.get_env("BDS_SSH_PORT") do
value when is_binary(value) and value != "" -> String.to_integer(value)
_other -> Application.get_env(:bds, :server)[:ssh_port] || 2222
end
end
@doc false
# Child spec that defers all filesystem work (key generation) to process
# start, so building a children list never touches the data dir.
def child_spec(_opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, []},
type: :worker
}
end
@doc false
def start_link do
ExRatatui.SSH.Daemon.start_link(daemon_opts())
end
end

View File

@@ -3,10 +3,17 @@ defmodule BDS.Settings do
Persistence layer for global application settings stored as key-value pairs in the database.
"""
import Ecto.Query, only: [from: 2]
alias BDS.Persistence
alias BDS.Repo
alias BDS.Settings.Setting
@spec list_global_settings() :: [{String.t(), String.t()}]
def list_global_settings do
Repo.all(from setting in Setting, order_by: setting.key, select: {setting.key, setting.value})
end
@spec get_global_setting(String.t()) :: String.t() | nil
def get_global_setting(key) do
case Repo.get(Setting, key) do
@@ -27,8 +34,12 @@ defmodule BDS.Settings do
})
|> Repo.insert_or_update()
|> case do
{:ok, _setting} -> :ok
{:error, reason} -> {:error, reason}
{:ok, _setting} ->
:ok = BDS.Events.settings_changed(key)
:ok
{:error, reason} ->
{:error, reason}
end
end
end

View File

@@ -6,6 +6,7 @@ defmodule BDS.Tags do
import Ecto.Query
alias BDS.Events
alias BDS.MapUtils
alias BDS.Persistence
alias BDS.Posts
@@ -40,6 +41,7 @@ defmodule BDS.Tags do
|> case do
{:ok, tag} ->
with :ok <- write_tags_json(project_id) do
:ok = Events.entity_changed("tag", tag.id, :created)
{:ok, tag}
end
@@ -130,6 +132,7 @@ defmodule BDS.Tags do
|> case do
{:ok, updated_tag} ->
with :ok <- write_tags_json(updated_tag.project_id) do
:ok = Events.entity_changed("tag", updated_tag.id, :updated)
{:ok, updated_tag}
end
@@ -165,6 +168,7 @@ defmodule BDS.Tags do
{:ok, post_ids} ->
with :ok <- rewrite_published_posts(post_ids),
:ok <- write_tags_json(tag.project_id) do
:ok = Events.entity_changed("tag", tag.id, :deleted)
{:ok, :deleted}
end
@@ -205,6 +209,7 @@ defmodule BDS.Tags do
{:ok, {updated_tag, post_ids}} ->
with :ok <- rewrite_published_posts(post_ids),
:ok <- write_tags_json(tag.project_id) do
:ok = Events.entity_changed("tag", updated_tag.id, :updated)
{:ok, updated_tag}
end
@@ -250,6 +255,8 @@ defmodule BDS.Tags do
{:ok, post_ids} ->
with :ok <- rewrite_published_posts(post_ids),
:ok <- write_tags_json(target_tag.project_id) do
Enum.each(source_tags, &Events.entity_changed("tag", &1.id, :deleted))
:ok = Events.entity_changed("tag", target_tag.id, :updated)
{:ok, :merged}
end

View File

@@ -17,6 +17,7 @@ defmodule BDS.Templates do
alias BDS.Projects
alias BDS.Repo
alias BDS.Slug
alias BDS.Events
alias BDS.Tags
alias BDS.Templates.Template
@@ -58,6 +59,7 @@ defmodule BDS.Templates do
serialize_template_file(template, template.content || "")
)
:ok = Events.entity_changed("template", template.id, :created)
{:ok, template}
end
end
@@ -102,6 +104,7 @@ defmodule BDS.Templates do
updated_at: updated_at
})
|> Repo.update()
|> Events.broadcast_result("template", :updated)
{:error, reason} ->
{:error, {:invalid_liquid, reason}}
@@ -149,6 +152,7 @@ defmodule BDS.Templates do
serialize_template_file(template, content)
)
:ok = Events.entity_changed("template", template.id, :created)
{:ok, template}
end
@@ -183,6 +187,7 @@ defmodule BDS.Templates do
affected_posts,
slug_changed?
) do
:ok = Events.entity_changed("template", updated_template.id, :updated)
{:ok, updated_template}
end
end
@@ -299,6 +304,7 @@ defmodule BDS.Templates do
case Repo.delete(template) do
{:ok, deleted_template} ->
delete_file_if_present(deleted_template.project_id, deleted_template.file_path)
:ok = Events.entity_changed("template", deleted_template.id, :deleted)
{:ok, :deleted}
{:error, changeset} ->

2174
lib/bds/tui.ex Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
defmodule BDS.UI.CodeEditor.Highlight do
@moduledoc """
Syntax highlighting for the TUI code editor (issue #32).
Wraps `ExRatatui.CodeBlock.highlight/3` with the editor's language
set: base lexing via syntect, plus pure-Elixir overlays for the
syntaxes syntect does not know — post macros (`[[gallery names="x"]]`)
over the markdown base and Liquid tags (`{{ … }}` / `{% … %}`) over
the HTML base. Mirrors the GUI Monaco language registrations in
`assets/js/monaco/languages.js`.
"""
alias BDS.UI.CodeEditor.Overlay
alias ExRatatui.Text.Line
alias ExRatatui.Widgets.CodeBlock
@type language :: :markdown_macros | :liquid | :lua | :text
@doc """
Highlights `content` for `language` using `theme`, returning one
`%ExRatatui.Text.Line{}` per source line.
The returned line count always matches `String.split(content, "\\n")`
(syntect drops trailing empty lines, but the textarea's line count is
authoritative for the cursor row, so they are padded back). Span
contents never contain newline characters.
"""
@spec highlight(String.t(), language(), CodeBlock.theme()) :: [Line.t()]
def highlight(content, language, theme) when is_binary(content) do
content
|> base_highlight(language, theme)
|> sanitize_lines()
|> pad_lines(source_line_count(content))
|> apply_overlay(language)
end
defp base_highlight(content, :markdown_macros, theme),
do: ExRatatui.CodeBlock.highlight(content, "markdown", theme)
defp base_highlight(content, :liquid, theme),
do: ExRatatui.CodeBlock.highlight(content, "html", theme)
defp base_highlight(content, :lua, theme),
do: ExRatatui.CodeBlock.highlight(content, "lua", theme)
defp base_highlight(content, :text, theme),
do: ExRatatui.CodeBlock.highlight(content, nil, theme)
# syntect appends the line terminator to the last span of a line;
# spans fed to `ExRatatui.Widgets.Paragraph` must not contain "\n".
defp sanitize_lines(lines) do
Enum.map(lines, fn %Line{spans: spans} = line ->
spans =
spans
|> Enum.map(&%{&1 | content: String.replace(&1.content, ~r/[\r\n]/, "")})
|> Enum.reject(&(&1.content == ""))
%{line | spans: spans}
end)
end
defp source_line_count(content), do: content |> String.split("\n") |> length()
defp pad_lines(lines, count) when length(lines) >= count, do: lines
defp pad_lines(lines, count),
do: lines ++ List.duplicate(%Line{}, count - length(lines))
defp apply_overlay(lines, :markdown_macros), do: Enum.map(lines, &Overlay.markdown_macros/1)
defp apply_overlay(lines, :liquid), do: Enum.map(lines, &Overlay.liquid/1)
defp apply_overlay(lines, _language), do: lines
end

View File

@@ -0,0 +1,225 @@
defmodule BDS.UI.CodeEditor.Overlay do
@moduledoc """
Re-styles `%ExRatatui.Text.Line{}` spans for the syntaxes the base
syntect lexer does not know: post macros (`[[gallery names="x"]]`) and
Liquid tags (`{{ … }}` / `{% … %}`).
Ranges are byte-based `{start, length, %Style{}}` triples applied with
patch semantics — the overlay's non-nil fields override the base span
style, so the theme background survives. Overlay colours mirror the
GUI Monaco theme (`assets/js/monaco/theme.js`): macro accents are the
custom `keyword.macro` / `attribute.name` / `attribute.value` rules,
Liquid accents are the vs-dark keyword/string/number base tokens.
"""
alias ExRatatui.Style
alias ExRatatui.Text.Line
alias ExRatatui.Text.Span
@type styled_range :: {non_neg_integer(), non_neg_integer(), Style.t()}
@macro_style %Style{fg: {:rgb, 197, 134, 192}, modifiers: [:bold]}
@macro_attr_style %Style{fg: {:rgb, 156, 220, 254}}
@macro_value_style %Style{fg: {:rgb, 206, 145, 120}}
@liquid_keyword_style %Style{fg: {:rgb, 86, 156, 214}}
@liquid_string_style %Style{fg: {:rgb, 206, 145, 120}}
@liquid_number_style %Style{fg: {:rgb, 181, 206, 168}}
@macro_pattern ~r/\[\[\s*[a-zA-Z][\w-]*[^]]*\]\]/
@macro_opener ~r/\A\[\[\s*[a-zA-Z][\w-]*/
@macro_attr_name ~r/[a-zA-Z][\w-]*(?=\s*=)/
@macro_quoted_value ~r/"[^"]*"/
@macro_bare_value ~r/[^\s=\[\]"]++(?!\s*=)/
@liquid_tag ~r/\{\{-?.*?-?\}\}|\{%-?.*?-?%\}/
@liquid_open ~r/\A\{[\{%]-?/
@liquid_close ~r/-?[\}%][\}%]\z/
@liquid_keywords ~r/\b(?:assign|capture|case|comment|cycle|decrement|echo|elsif|else|endcase|endcapture|endif|endfor|endunless|endcomment|for|if|include|increment|liquid|paginate|raw|render|tablerow|unless|when|true|false|nil|blank|empty|contains)\b/
@liquid_filter ~r/\|\s*[a-zA-Z_][\w-]*/
@liquid_string ~r/"[^"]*"|'[^']*'/
@liquid_number ~r/\b\d+(?:\.\d+)?\b/
@doc """
Recolours every `[[macro]]` on the line, preserving the base styling
of the surrounding markdown.
"""
@spec markdown_macros(Line.t()) :: Line.t()
def markdown_macros(%Line{} = line) do
apply_ranges(line, markdown_macro_ranges(line_text(line)))
end
@doc """
Recolours every Liquid output/tag expression on the line, preserving
the base styling of the surrounding HTML.
"""
@spec liquid(Line.t()) :: Line.t()
def liquid(%Line{} = line) do
apply_ranges(line, liquid_ranges(line_text(line)))
end
@doc """
Byte ranges of every macro on `text`: `[[` + name and `]]` in the
macro style, attribute names and values in their accent styles.
"""
@spec markdown_macro_ranges(String.t()) :: [styled_range()]
def markdown_macro_ranges(text) do
text
|> scan(@macro_pattern)
|> Enum.flat_map(fn {start, len} ->
macro = binary_part(text, start, len)
macro
|> macro_inner_ranges()
|> Enum.map(fn {inner_start, inner_len, style} ->
{start + inner_start, inner_len, style}
end)
end)
end
@doc """
Byte ranges (relative to the start of `macro`, including its `[[` and
`]]`) of the styled sub-parts of a single macro match. Quoted values
are masked before scanning for attribute names and bare values so
nothing matches inside a quoted string.
"""
@spec macro_inner_ranges(String.t()) :: [styled_range()]
def macro_inner_ranges(macro) do
[{0, opener_len}] = Regex.run(@macro_opener, macro, return: :index)
inner = binary_part(macro, opener_len, byte_size(macro) - opener_len - 2)
quoted = scan(inner, @macro_quoted_value)
masked = mask(inner, quoted)
inner_ranges =
with_style(quoted, @macro_value_style) ++
(masked |> scan(@macro_attr_name) |> with_style(@macro_attr_style)) ++
(masked |> scan(@macro_bare_value) |> with_style(@macro_value_style))
[{0, opener_len, @macro_style}, {byte_size(macro) - 2, 2, @macro_style}] ++
offset_ranges(inner_ranges, opener_len)
end
@doc """
Byte ranges of every Liquid expression on `text`: the `{{`/`}}` and
`{%`/`%}` delimiters, tag keywords and filters in the keyword style,
strings and numbers in their accent styles. Strings are masked before
scanning so keywords/numbers inside them keep the string style.
"""
@spec liquid_ranges(String.t()) :: [styled_range()]
def liquid_ranges(text) do
text
|> scan(@liquid_tag)
|> Enum.flat_map(fn {start, len} ->
tag = binary_part(text, start, len)
[{0, open_len}] = Regex.run(@liquid_open, tag, return: :index)
[{close_start, close_len}] = Regex.run(@liquid_close, tag, return: :index)
inner = binary_part(tag, open_len, close_start - open_len)
strings = scan(inner, @liquid_string)
masked = mask(inner, strings)
inner_ranges =
with_style(strings, @liquid_string_style) ++
(masked |> scan(@liquid_keywords) |> with_style(@liquid_keyword_style)) ++
(masked |> scan(@liquid_filter) |> with_style(@liquid_keyword_style)) ++
(masked |> scan(@liquid_number) |> with_style(@liquid_number_style))
[{start, open_len, @liquid_keyword_style}, {start + close_start, close_len, @liquid_keyword_style}] ++
offset_ranges(inner_ranges, start + open_len)
end)
end
@doc """
Re-splits a line's spans at the given byte ranges, patch-merging each
range's style over the base span style it covers. Overlapping ranges
resolve to the one starting earliest (strings/quotes therefore win
over keywords matched inside them).
"""
@spec apply_ranges(Line.t(), [styled_range()]) :: Line.t()
def apply_ranges(%Line{} = line, []), do: line
def apply_ranges(%Line{spans: spans} = line, ranges) do
ranges = Enum.sort_by(ranges, &elem(&1, 0))
{new_spans, _offset} =
Enum.map_reduce(spans, 0, fn span, offset ->
{split_span(span, offset, ranges), offset + byte_size(span.content)}
end)
%{line | spans: new_spans |> List.flatten() |> Enum.reject(&(&1.content == ""))}
end
@doc """
Patch-merges `patch` over `base`: non-nil colours override, modifiers
union. Used for overlay accents, the cursor cell and the current-line
highlight so the theme background/foreground survives underneath.
"""
@spec merge_style(Style.t(), Style.t()) :: Style.t()
def merge_style(%Style{} = base, %Style{} = patch) do
%Style{
fg: patch.fg || base.fg,
bg: patch.bg || base.bg,
underline_color: patch.underline_color || base.underline_color,
modifiers: Enum.uniq(base.modifiers ++ patch.modifiers)
}
end
defp split_span(%Span{} = span, span_start, ranges) do
span_end = span_start + byte_size(span.content)
cuts =
ranges
|> Enum.flat_map(fn {range_start, range_len, _style} ->
range_end = range_start + range_len
if range_end > span_start and range_start < span_end do
[max(range_start, span_start), min(range_end, span_end)]
else
[]
end
end)
|> Enum.filter(&(&1 > span_start and &1 < span_end))
|> Enum.uniq()
|> Enum.sort()
[span_start | cuts ++ [span_end]]
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [segment_start, segment_end] ->
content = binary_part(span.content, segment_start - span_start, segment_end - segment_start)
style =
case Enum.find(ranges, &covers?(&1, segment_start, segment_end)) do
nil -> span.style
{_start, _len, patch} -> merge_style(span.style, patch)
end
%Span{content: content, style: style}
end)
end
defp covers?({range_start, range_len, _style}, segment_start, segment_end) do
segment_start >= range_start and segment_end <= range_start + range_len
end
defp line_text(line), do: Enum.map_join(line.spans, & &1.content)
defp scan(text, pattern) do
pattern
|> Regex.scan(text, return: :index)
|> Enum.map(fn [{start, len}] -> {start, len} end)
end
defp with_style(ranges, style), do: Enum.map(ranges, fn {start, len} -> {start, len, style} end)
defp offset_ranges(ranges, offset),
do: Enum.map(ranges, fn {start, len, style} -> {start + offset, len, style} end)
# Blanks out the given byte ranges with spaces so later scans cannot
# match inside them (offsets stay intact).
defp mask(text, ranges) do
Enum.reduce(ranges, text, fn {start, len}, acc ->
binary_part(acc, 0, start) <>
String.duplicate(" ", len) <>
binary_part(acc, start + len, byte_size(acc) - start - len)
end)
end
end

View File

@@ -0,0 +1,205 @@
defmodule BDS.UI.CodeEditor do
@moduledoc """
Syntax-highlighted, word-wrapping code editor widget (issue #32).
The editing engine stays Rust-owned (`ExRatatui.textarea_*` — buffer,
cursor, undo/redo, clipboard); this widget only re-renders the buffer
every frame through a styling + wrap pipeline:
1. read the buffer and cursor off the textarea reference
2. highlight with `BDS.UI.CodeEditor.Highlight`
3. patch the current-line background into the cursor's source line
4. cut the span at the cursor column and splice in a cursor-styled
cell (the cell-caret rides the paragraph's wrapping)
5. compute the vertical scroll offset so the cursor's visual row
stays inside the window
6. emit a wrapping `ExRatatui.Widgets.Paragraph`
Fields:
* `:textarea` — the Rust textarea reference (required)
* `:language` — `:markdown_macros | :liquid | :lua | :text`
* `:theme` — syntect theme, default `:base16_ocean_dark`
* `:wrap` — soft-wrap long lines, default `true`
* `:cursor_style` — patch style for the cell under the cursor
* `:line_highlight_style` — patch style for the cursor's source line
* `:block` — optional `%ExRatatui.Widgets.Block{}` container
* `:scroll` — previous `{vertical, horizontal}` offset; the render
adjusts it minimally so the cursor stays visible
There is deliberately no line-number gutter. The wrap math assumes
Latin cell widths (1 char = 1 cell) — wide graphemes (CJK, emoji)
can scroll slightly off; noted as a TODO, out of scope for now.
"""
alias ExRatatui.Style
alias ExRatatui.Widgets.Block
alias ExRatatui.Widgets.CodeBlock
@type language :: BDS.UI.CodeEditor.Highlight.language()
@type t :: %__MODULE__{
textarea: reference() | nil,
language: language(),
theme: CodeBlock.theme(),
wrap: boolean(),
cursor_style: Style.t(),
line_highlight_style: Style.t(),
block: Block.t() | nil,
scroll: {non_neg_integer(), non_neg_integer()}
}
defstruct textarea: nil,
language: :text,
theme: :base16_ocean_dark,
wrap: true,
cursor_style: %Style{bg: :cyan},
line_highlight_style: %Style{},
block: nil,
scroll: {0, 0}
end
defimpl ExRatatui.Widget, for: BDS.UI.CodeEditor do
alias BDS.UI.CodeEditor
alias BDS.UI.CodeEditor.Highlight
alias BDS.UI.CodeEditor.Overlay
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Text.Line
alias ExRatatui.Text.Span
alias ExRatatui.Widgets.Block
alias ExRatatui.Widgets.Paragraph
def render(%CodeEditor{textarea: nil} = editor, %Rect{} = rect) do
[{%Paragraph{text: "", wrap: editor.wrap, block: editor.block}, rect}]
end
def render(%CodeEditor{} = editor, %Rect{} = rect) do
content = ExRatatui.textarea_get_value(editor.textarea)
{row, col} = ExRatatui.textarea_cursor(editor.textarea)
source_lines = String.split(content, "\n")
inner = inner_rect(rect, editor.block)
width = max(inner.width, 1)
height = max(inner.height, 1)
lines =
content
|> Highlight.highlight(editor.language, editor.theme)
|> highlight_current_line(row, editor.line_highlight_style)
|> splice_cursor(source_lines, row, col, editor)
top = scroll_top(source_lines, row, col, width, height, elem(editor.scroll, 0))
[
{%Paragraph{
text: lines,
wrap: editor.wrap,
scroll: {top, 0},
block: editor.block
}, rect}
]
end
defp inner_rect(rect, nil), do: rect
defp inner_rect(rect, %Block{borders: borders}) do
left = if border?(borders, :left), do: 1, else: 0
right = if border?(borders, :right), do: 1, else: 0
top = if border?(borders, :top), do: 1, else: 0
bottom = if border?(borders, :bottom), do: 1, else: 0
%Rect{
x: rect.x + left,
y: rect.y + top,
width: max(rect.width - left - right, 1),
height: max(rect.height - top - bottom, 1)
}
end
defp border?(borders, side), do: :all in borders or side in borders
defp highlight_current_line(lines, row, %Style{} = style) do
if style == %Style{} do
lines
else
List.update_at(lines, row, fn %Line{} = line ->
%{line | spans: Enum.map(line.spans, &%{&1 | style: Overlay.merge_style(&1.style, style)})}
end)
end
end
defp splice_cursor(lines, source_lines, row, col, editor) do
List.update_at(lines, row, fn %Line{} = line ->
if col >= cursor_line_length(source_lines, row) do
append_cursor_cell(line, editor)
else
splice_cursor_cell(line, col, editor.cursor_style)
end
end)
end
defp cursor_line_length(source_lines, row) do
source_lines |> Enum.at(row, "") |> String.length()
end
# Past the last character there is no span to cut into — append a
# cursor cell instead. The line highlight bleeds through when the
# cursor style leaves the background alone.
defp append_cursor_cell(%Line{} = line, editor) do
style = Overlay.merge_style(editor.line_highlight_style, editor.cursor_style)
%{line | spans: line.spans ++ [%Span{content: " ", style: style}]}
end
defp splice_cursor_cell(%Line{} = line, col, cursor_style) do
{new_spans, _offset} =
Enum.map_reduce(line.spans, 0, fn span, offset ->
len = String.length(span.content)
if col >= offset and col < offset + len do
{cut_span(span, col - offset, cursor_style), offset + len}
else
{[span], offset + len}
end
end)
%{line | spans: List.flatten(new_spans)}
end
defp cut_span(span, at, cursor_style) do
[
%Span{content: String.slice(span.content, 0, at), style: span.style},
%Span{
content: String.slice(span.content, at, 1),
style: Overlay.merge_style(span.style, cursor_style)
},
%Span{content: String.slice(span.content, at + 1, String.length(span.content)), style: span.style}
]
|> Enum.reject(&(&1.content == ""))
end
# Latin-width assumption (1 char = 1 cell): wide graphemes wrap
# differently than counted here. TODO: CJK/emoji cell widths.
defp scroll_top(source_lines, row, col, width, height, prev_top) do
visual_row =
source_lines
|> Enum.take(row)
|> Enum.map(&visual_rows(&1, width))
|> Enum.sum()
|> Kernel.+(div(col, width))
cond do
visual_row < prev_top -> visual_row
visual_row >= prev_top + height -> visual_row - height + 1
true -> prev_top
end
|> max(0)
end
defp visual_rows(line, width) do
line
|> String.length()
|> max(1)
|> then(&div(&1 + width - 1, width))
end
end

View File

@@ -20,6 +20,9 @@ defmodule BDS.UI.MenuBar do
%{separator: true},
%{id: :open_in_browser},
%{separator: true},
%{id: :connect_server},
%{id: :disconnect_server},
%{separator: true},
%{id: :open_data_folder},
%{separator: true},
%{id: :close_tab},

View File

@@ -0,0 +1,107 @@
defmodule BDS.UI.PostEditor.Draft do
@moduledoc """
Pure draft/form logic for the post editor (issue #26, phase 3).
Shared by the LiveView shell and the TUI: builds the persisted form for a
post (canonical language or translation), normalizes editor params, and
provides the small pure helpers both renderers need. No socket, no
process state — callers own the draft maps.
"""
alias BDS.Posts
alias BDS.Posts.{Post, Translation}
alias BDS.UI.PostEditor.Metadata
def normalize_mode(mode) when mode in [:markdown, :preview], do: mode
def normalize_mode("visual"), do: :markdown
def normalize_mode("preview"), do: :preview
def normalize_mode(_mode), do: :markdown
def normalize_language(value, fallback) do
case value |> to_string() |> String.trim() do
"" -> fallback
normalized -> String.downcase(normalized)
end
end
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
def persisted_form(%Post{} = post, metadata, active_language) do
persisted_form(post, metadata, active_language, Metadata.translations(post.id))
end
def persisted_form(post, metadata, active_language, translations) do
canonical_language = Metadata.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
def save_state_for_action(:publish), do: :published
def save_state_for_action(_action), do: :saved
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
def record_status(%Translation{status: status}), do: status || :draft
def record_status(%Post{status: status}), do: status || :draft
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), do: BDS.Values.truthy?(value)
defp blank_to_nil(value) do
value
|> to_string()
|> String.trim()
|> case do
"" -> nil
trimmed -> trimmed
end
end
end

View File

@@ -1,4 +1,4 @@
defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
defmodule BDS.UI.PostEditor.ListValues do
@moduledoc false
alias BDS.{Metadata, Tags}

View File

@@ -1,4 +1,4 @@
defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
defmodule BDS.UI.PostEditor.Metadata do
@moduledoc false
import Ecto.Query
@@ -20,7 +20,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
@spec canonical_language(term(), term()) :: term()
def canonical_language(post, metadata) do
BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language(
BDS.UI.PostEditor.Draft.normalize_language(
post.language,
metadata.main_language || "en"
)

View File

@@ -1,17 +1,21 @@
defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
@moduledoc false
defmodule BDS.UI.PostEditor.Persistence do
@moduledoc """
Save/publish/discard workflow for post drafts, shared by the LiveView
shell and the TUI (issue #26, phase 3). Routes canonical-language edits
to the post itself and other languages to translations.
"""
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, PostMetadata}
alias BDS.UI.PostEditor.{Draft, Metadata}
use Gettext, backend: BDS.Gettext
@spec persist(term(), term(), term(), term(), term()) :: term()
def persist(%Post{} = post, draft, active_language, metadata, action) do
canonical_language = PostMetadata.canonical_language(post, metadata)
translations = PostMetadata.translations(post.id)
canonical_language = Metadata.canonical_language(post, metadata)
translations = Metadata.translations(post.id)
if DraftManagement.editing_canonical_language?(
if Draft.editing_canonical_language?(
translations,
active_language,
canonical_language
@@ -28,11 +32,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
@spec discard(term(), term(), term()) :: term()
def discard(%Post{} = post, active_language, metadata) do
canonical_language = PostMetadata.canonical_language(post, metadata)
current_translations = PostMetadata.translations(post.id)
canonical_language = Metadata.canonical_language(post, metadata)
current_translations = Metadata.translations(post.id)
cond do
not DraftManagement.editing_canonical_language?(
not Draft.editing_canonical_language?(
current_translations,
active_language,
canonical_language

502
lib/bds/ui/settings_form.ex Normal file
View File

@@ -0,0 +1,502 @@
defmodule BDS.UI.SettingsForm do
@moduledoc """
Renderer-agnostic preferences forms (issue #29).
Exposes each settings section of the GUI settings editor as a flat list
of typed fields (`:text`, `:bool`, `:enum`, `:info`, `:action`) that the
TUI can render and edit generically. `save/3` writes through the same backends
as the GUI editor — `BDS.Metadata`, `BDS.Settings`, `BDS.AI` and
`BDS.MCP.AgentConfig` — so both frontends operate on the same
preferences.
"""
use Gettext, backend: BDS.Gettext
import Ecto.Query
alias BDS.Desktop.ShellLive.SettingsEditor.AISettings
alias BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories
alias BDS.Desktop.ShellLive.SettingsEditor.MCPConfig
alias BDS.I18n
alias BDS.MCP.AgentConfig
alias BDS.Metadata
alias BDS.Projects
alias BDS.Repo
alias BDS.Settings
alias BDS.Templates.Template
@type field :: %{
key: String.t(),
label: String.t(),
type: :text | :bool | :enum | :info | :action,
value: term(),
options: [String.t()]
}
@type form :: %{section: String.t(), title: String.t(), fields: [field()]}
# ── Load ─────────────────────────────────────────────────────────────────
@spec load(String.t(), String.t()) :: form()
def load("project", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
form("project", dgettext("ui", "Project"), [
text("name", dgettext("ui", "Name"), metadata.name),
text("description", dgettext("ui", "Description"), metadata.description),
text("public_url", dgettext("ui", "Public URL"), metadata.public_url),
enum(
"main_language",
dgettext("ui", "Main Language"),
metadata.main_language || "en",
supported_language_codes()
),
text("default_author", dgettext("ui", "Default Author"), metadata.default_author),
text(
"max_posts_per_page",
dgettext("ui", "Posts per Page"),
Integer.to_string(metadata.max_posts_per_page)
),
text(
"image_import_concurrency",
dgettext("ui", "Image Import Concurrency"),
Integer.to_string(metadata.image_import_concurrency)
),
enum(
"blogmark_category",
dgettext("ui", "Blogmark Category"),
metadata.blogmark_category || List.first(metadata.categories) || "article",
metadata.categories
),
text(
"blog_languages",
dgettext("ui", "Blog Languages (comma-separated)"),
Enum.join(metadata.blog_languages, ", ")
),
bool(
"semantic_similarity_enabled",
dgettext("ui", "Semantic Similarity"),
metadata.semantic_similarity_enabled
)
])
end
def load("editor", _project_id) do
stored = editor_settings()
form("editor", dgettext("ui", "Editor"), [
enum("default_mode", dgettext("ui", "Default Editor Mode"), stored["default_mode"], [
"wysiwyg",
"markdown",
"preview"
]),
enum("diff_view_style", dgettext("ui", "Diff View Style"), stored["diff_view_style"], [
"inline",
"side-by-side"
]),
bool("wrap_long_lines", dgettext("ui", "Wrap Long Lines"), stored["wrap_long_lines"]),
bool(
"hide_unchanged_regions",
dgettext("ui", "Hide Unchanged Regions"),
stored["hide_unchanged_regions"]
)
])
end
def load("content", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
post_templates = [""] ++ template_slugs(project_id, :post)
list_templates = [""] ++ template_slugs(project_id, :list)
category_fields =
metadata
|> ManagedCategories.category_rows()
|> Enum.flat_map(fn row ->
[
info("cat:#{row.name}", "── " <> row.name, ""),
text("cat:#{row.name}:title", dgettext("ui", "Title"), row.title),
bool("cat:#{row.name}:render_in_lists", dgettext("ui", "Render in Lists"), row.render_in_lists),
bool("cat:#{row.name}:show_title", dgettext("ui", "Show Title"), row.show_title),
enum(
"cat:#{row.name}:post_template_slug",
dgettext("ui", "Post Template"),
row.post_template_slug || "",
post_templates
),
enum(
"cat:#{row.name}:list_template_slug",
dgettext("ui", "List Template"),
row.list_template_slug || "",
list_templates
)
]
end)
form(
"content",
dgettext("ui", "Content"),
[text("new_category", dgettext("ui", "New Category"), "")] ++ category_fields
)
end
def load("ai", _project_id) do
stored = AISettings.ai_form(%{})
form("ai", dgettext("ui", "AI"), [
bool("offline_mode", dgettext("ui", "Airplane Mode"), stored["offline_mode"]),
text("system_prompt", dgettext("ui", "System Prompt"), stored["system_prompt"]),
text("online_url", dgettext("ui", "Online Endpoint URL"), stored["online_url"]),
text("online_api_key", dgettext("ui", "Online API Key"), stored["online_api_key"]),
text("online_chat_model", dgettext("ui", "Online Chat Model"), stored["online_chat_model"]),
bool("online_chat_tools", dgettext("ui", "Online Chat Tool Calls"), stored["online_chat_tools"]),
bool(
"online_chat_disable_reasoning",
dgettext("ui", "Online Chat Disable Reasoning"),
stored["online_chat_disable_reasoning"]
),
text("online_title_model", dgettext("ui", "Online Title Model"), stored["online_title_model"]),
text(
"online_image_analysis_model",
dgettext("ui", "Online Image Model"),
stored["online_image_analysis_model"]
),
bool("online_chat_images", dgettext("ui", "Online Image Support"), stored["online_chat_images"]),
text("offline_url", dgettext("ui", "Offline Endpoint URL"), stored["offline_url"]),
text("offline_api_key", dgettext("ui", "Offline API Key"), stored["offline_api_key"]),
text("offline_chat_model", dgettext("ui", "Offline Chat Model"), stored["offline_chat_model"]),
bool("offline_chat_tools", dgettext("ui", "Offline Chat Tool Calls"), stored["offline_chat_tools"]),
bool(
"offline_chat_disable_reasoning",
dgettext("ui", "Offline Chat Disable Reasoning"),
stored["offline_chat_disable_reasoning"]
),
text("offline_title_model", dgettext("ui", "Offline Title Model"), stored["offline_title_model"]),
text(
"offline_image_analysis_model",
dgettext("ui", "Offline Image Model"),
stored["offline_image_analysis_model"]
),
bool("offline_chat_images", dgettext("ui", "Offline Image Support"), stored["offline_chat_images"])
])
end
def load("technology", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
form("technology", dgettext("ui", "Technology"), [
bool(
"semantic_similarity_enabled",
dgettext("ui", "Semantic Similarity"),
metadata.semantic_similarity_enabled
)
])
end
def load("publishing", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
prefs = metadata.publishing_preferences
form("publishing", dgettext("ui", "Publishing"), [
text("ssh_host", dgettext("ui", "SSH Host"), Map.get(prefs, "ssh_host", "")),
text("ssh_user", dgettext("ui", "SSH User"), Map.get(prefs, "ssh_user", "")),
text("ssh_remote_path", dgettext("ui", "SSH Remote Path"), Map.get(prefs, "ssh_remote_path", "")),
enum("ssh_mode", dgettext("ui", "SSH Mode"), Map.get(prefs, "ssh_mode", "scp") || "scp", [
"scp",
"rsync"
])
])
end
def load("data", project_id) do
project = Projects.get_project(project_id)
data_path = if project, do: Projects.project_data_dir(project), else: ""
form("data", dgettext("ui", "Data"), [
info("data_path", dgettext("ui", "Data Folder"), data_path),
info(
"data_hint",
dgettext("ui", "Maintenance"),
dgettext("ui", "Rebuild and maintenance commands are available under the : prompt.")
),
action(
"install_cli",
dgettext("ui", "Command Line Tool"),
dgettext("ui", "Install CLI Tool")
)
])
end
def load("mcp", _project_id) do
fields =
Enum.map(MCPConfig.mcp_rows(), fn row ->
if row.supported? do
bool("mcp:#{row.id}", row.label, row.configured?)
else
info("mcp:#{row.id}", row.label, dgettext("ui", "not supported yet"))
end
end)
form("mcp", dgettext("ui", "MCP"), fields)
end
def load("style", project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
current = if metadata.pico_theme in [nil, ""], do: "default", else: metadata.pico_theme
form("style", dgettext("ui", "Style"), [
enum("pico_theme", dgettext("ui", "Theme"), current, Metadata.supported_pico_themes())
])
end
def load(section, _project_id) do
form(section, section, [])
end
# ── Save ─────────────────────────────────────────────────────────────────
@spec save(String.t(), String.t(), %{String.t() => term()}) :: :ok | {:error, term()}
def save("project", project_id, values) do
save_project_metadata(project_id, %{
name: blank_to_nil(values["name"]),
description: blank_to_nil(values["description"]),
public_url: blank_to_nil(values["public_url"]),
main_language: blank_to_nil(values["main_language"]),
default_author: blank_to_nil(values["default_author"]),
max_posts_per_page: parse_integer(values["max_posts_per_page"], 50),
image_import_concurrency: parse_integer(values["image_import_concurrency"], 4),
blogmark_category: blank_to_nil(values["blogmark_category"]),
blog_languages: split_languages(values["blog_languages"]),
semantic_similarity_enabled: values["semantic_similarity_enabled"] == true
})
end
def save("editor", _project_id, values) do
with :ok <- Settings.put_global_setting("ui.preferred_editor_mode", values["default_mode"]),
:ok <- Settings.put_global_setting("ui.git_diff_view_style", values["diff_view_style"]),
:ok <-
Settings.put_global_setting(
"ui.git_diff_word_wrap",
boolean_string(values["wrap_long_lines"])
) do
Settings.put_global_setting(
"ui.git_diff_hide_unchanged_regions",
boolean_string(values["hide_unchanged_regions"])
)
end
end
def save("content", project_id, values) do
with :ok <- maybe_add_category(project_id, values["new_category"]) do
values
|> category_settings_by_name()
|> Enum.reduce_while(:ok, fn {category, settings}, _acc ->
case Metadata.update_category_settings(project_id, category, settings) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
end
def save("ai", _project_id, values) do
AISettings.save_attrs(%{
online_url: blank_to_nil(values["online_url"]),
online_api_key: blank_to_nil(values["online_api_key"]),
online_chat_model: blank_to_nil(values["online_chat_model"]),
online_chat_tools: values["online_chat_tools"] == true,
online_chat_disable_reasoning: values["online_chat_disable_reasoning"] == true,
online_title_model: blank_to_nil(values["online_title_model"]),
online_image_analysis_model: blank_to_nil(values["online_image_analysis_model"]),
online_chat_images: values["online_chat_images"] == true,
offline_url: blank_to_nil(values["offline_url"]),
offline_api_key: blank_to_nil(values["offline_api_key"]),
offline_mode: values["offline_mode"] == true,
offline_chat_model: blank_to_nil(values["offline_chat_model"]),
offline_chat_tools: values["offline_chat_tools"] == true,
offline_chat_disable_reasoning: values["offline_chat_disable_reasoning"] == true,
offline_title_model: blank_to_nil(values["offline_title_model"]),
offline_image_analysis_model: blank_to_nil(values["offline_image_analysis_model"]),
offline_chat_images: values["offline_chat_images"] == true,
system_prompt: to_string(values["system_prompt"] || "")
})
end
def save("technology", project_id, values) do
save_project_metadata(project_id, %{
semantic_similarity_enabled: values["semantic_similarity_enabled"] == true
})
end
def save("publishing", project_id, values) do
case Metadata.set_publishing_preferences(project_id, %{
ssh_host: blank_to_nil(values["ssh_host"]),
ssh_user: blank_to_nil(values["ssh_user"]),
ssh_remote_path: blank_to_nil(values["ssh_remote_path"]),
ssh_mode: values["ssh_mode"] || "scp"
}) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
end
def save("mcp", _project_id, values) do
MCPConfig.mcp_rows()
|> Enum.filter(& &1.supported?)
|> Enum.reduce_while(:ok, fn row, _acc ->
case toggle_mcp_agent(row, Map.get(values, "mcp:#{row.id}", row.configured?)) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
def save("style", project_id, values) do
save_project_metadata(project_id, %{pico_theme: values["pico_theme"]})
end
def save(_section, _project_id, _values), do: :ok
# ── Actions ──────────────────────────────────────────────────────────────
@doc """
Runs an `:action` field (issue #25) and returns a localized result
message for the invoking frontend (GUI toast or TUI status line).
"""
@spec run_action(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def run_action("install_cli") do
case BDS.CLI.Install.install() do
{:ok, path} ->
{:ok, dgettext("ui", "CLI tool installed to %{path}", path: path)}
{:error, :no_release} ->
{:error, dgettext("ui", "The CLI tool can only be installed from the packaged application")}
{:error, reason} ->
{:error, dgettext("ui", "Installing the CLI tool failed: %{reason}", reason: inspect(reason))}
end
end
def run_action(_key), do: {:error, dgettext("ui", "Unknown settings action")}
# ── Helpers ──────────────────────────────────────────────────────────────
defp form(section, title, fields), do: %{section: section, title: title, fields: fields}
defp text(key, label, value),
do: %{key: key, label: label, type: :text, value: to_string(value || ""), options: []}
defp bool(key, label, value),
do: %{key: key, label: label, type: :bool, value: value == true, options: []}
defp enum(key, label, value, options),
do: %{key: key, label: label, type: :enum, value: to_string(value || ""), options: options}
defp info(key, label, value),
do: %{key: key, label: label, type: :info, value: value, options: []}
defp action(key, label, caption),
do: %{key: key, label: label, type: :action, value: caption, options: []}
defp editor_settings do
%{
"default_mode" => Settings.get_global_setting("ui.preferred_editor_mode") || "markdown",
"diff_view_style" => Settings.get_global_setting("ui.git_diff_view_style") || "inline",
"wrap_long_lines" => Settings.get_global_setting("ui.git_diff_word_wrap") == "true",
"hide_unchanged_regions" =>
Settings.get_global_setting("ui.git_diff_hide_unchanged_regions") == "true"
}
end
defp supported_language_codes, do: Enum.map(I18n.supported_languages(), & &1.code)
defp template_slugs(project_id, kind) do
Repo.all(
from template in Template,
where: template.project_id == ^project_id and template.kind == ^kind,
order_by: [asc: template.slug],
select: template.slug
)
end
defp save_project_metadata(project_id, overrides) do
case Metadata.update_project_metadata(project_id, overrides) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp maybe_add_category(project_id, name) do
case String.trim(to_string(name || "")) do
"" ->
:ok
category ->
case Metadata.add_category(project_id, category) do
{:ok, _metadata} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
defp category_settings_by_name(values) do
values
|> Enum.flat_map(fn {key, value} ->
case String.split(key, ":", parts: 3) do
["cat", name, property] -> [{name, property, value}]
_other -> []
end
end)
|> Enum.group_by(fn {name, _property, _value} -> name end)
|> Enum.map(fn {name, entries} ->
{name, Map.new(entries, fn {_name, property, value} -> category_setting(property, value) end)}
end)
end
defp category_setting("title", value), do: {:title, blank_to_nil(value)}
defp category_setting("render_in_lists", value), do: {:render_in_lists, value == true}
defp category_setting("show_title", value), do: {:show_title, value == true}
defp category_setting("post_template_slug", value), do: {:post_template_slug, blank_to_nil(value)}
defp category_setting("list_template_slug", value), do: {:list_template_slug, blank_to_nil(value)}
defp toggle_mcp_agent(%{configured?: configured?}, wanted) when wanted == configured?, do: :ok
defp toggle_mcp_agent(%{id: agent_id}, true) do
case AgentConfig.add_to_config(agent_id, install_root: Application.app_dir(:bds)) do
{:ok, _payload} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp toggle_mcp_agent(%{id: agent_id}, _wanted) do
case AgentConfig.remove_from_config(agent_id) do
{:ok, _payload} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp split_languages(value) do
value
|> to_string()
|> String.split(~r/[,\s]+/, trim: true)
|> Enum.uniq()
end
defp parse_integer(value, fallback) do
case Integer.parse(to_string(value || "")) do
{parsed, _rest} -> parsed
:error -> fallback
end
end
defp boolean_string(true), do: "true"
defp boolean_string(_value), do: "false"
defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do
case String.trim(to_string(value)) do
"" -> nil
trimmed -> trimmed
end
end
end

View File

@@ -363,6 +363,7 @@ defmodule BDS.UI.Sidebar do
|> maybe_where_search(filters.search)
|> maybe_where_year(filters.year)
|> maybe_where_month(filters.month)
|> maybe_where_day(filters.day)
|> maybe_where_all_tags(filters.tags)
|> maybe_where_all_categories(filters.categories)
end
@@ -424,6 +425,25 @@ defmodule BDS.UI.Sidebar do
)
end
# Day-of-month filter used by the TUI's ISO-date search (yyyy-mm-dd);
# always combined with year and month by the caller.
defp maybe_where_day(query, nil), do: query
defp maybe_where_day(query, day) do
day_str = String.pad_leading(to_string(day), 2, "0")
where(
query,
[p],
fragment(
"strftime('%d', datetime(COALESCE(?, ?) / 1000, 'unixepoch')) = ?",
p.published_at,
p.updated_at,
^day_str
)
)
end
defp maybe_where_all_tags(query, []), do: query
defp maybe_where_all_tags(query, tags), do: where_all_in_json_array(query, tags, :tags)
@@ -609,6 +629,7 @@ defmodule BDS.UI.Sidebar do
|> maybe_where_media_search(filters.search)
|> maybe_where_media_year(filters.year)
|> maybe_where_media_month(filters.month)
|> maybe_where_media_day(filters.day)
|> maybe_where_all_media_tags(filters.tags)
end
@@ -667,6 +688,22 @@ defmodule BDS.UI.Sidebar do
)
end
defp maybe_where_media_day(query, nil), do: query
defp maybe_where_media_day(query, day) do
day_str = String.pad_leading(to_string(day), 2, "0")
where(
query,
[m],
fragment(
"strftime('%d', datetime(? / 1000, 'unixepoch')) = ?",
m.updated_at,
^day_str
)
)
end
defp maybe_where_all_media_tags(query, []), do: query
defp maybe_where_all_media_tags(query, tags),
@@ -1028,6 +1065,7 @@ defmodule BDS.UI.Sidebar do
search: normalize_string(BDS.MapUtils.attr(params, :search)),
year: normalize_integer(BDS.MapUtils.attr(params, :year)),
month: normalize_integer(BDS.MapUtils.attr(params, :month)),
day: normalize_integer(BDS.MapUtils.attr(params, :day)),
tags: normalize_string_list(BDS.MapUtils.attr(params, :tags)),
categories: normalize_string_list(BDS.MapUtils.attr(params, :categories)),
display_limit:
@@ -1045,6 +1083,7 @@ defmodule BDS.UI.Sidebar do
search: nil,
year: nil,
month: nil,
day: nil,
tags: [],
categories: [],
display_limit: @default_page_size
@@ -1052,8 +1091,8 @@ defmodule BDS.UI.Sidebar do
end
defp filter_active?(filters) do
present?(filters.search) or not is_nil(filters.year) or filters.tags != [] or
filters.categories != []
present?(filters.search) or not is_nil(filters.year) or not is_nil(filters.day) or
filters.tags != [] or filters.categories != []
end
defp post_search_blob(post) do

240
lib/bds/ui/tags_panel.ex Normal file
View File

@@ -0,0 +1,240 @@
defmodule BDS.UI.TagsPanel do
@moduledoc """
Renderer-agnostic tag management core (issue #34).
Exposes the same tag operations the GUI tags editor
(`BDS.Desktop.ShellLive.TagsEditor`) performs — create, rename, colour,
post template, delete, merge, sync-from-posts — as plain functions with
localized result messages, so the TUI tags panel and the GUI operate on
the same `BDS.Tags` backend.
"""
import Ecto.Query
use Gettext, backend: BDS.Gettext
alias BDS.Posts.Post
alias BDS.Repo
alias BDS.Tags
alias BDS.Tags.Tag
alias BDS.Templates.Template
@colour_presets ~w(
#ef4444 #f97316 #f59e0b #eab308 #84cc16
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
#ec4899 #64748b
)
@type tag_entry :: %{
id: String.t(),
name: String.t(),
color: String.t() | nil,
post_template_slug: String.t() | nil,
count: non_neg_integer()
}
@spec colour_presets() :: [String.t()]
def colour_presets, do: @colour_presets
@doc "All project tags with their post usage counts, plus template slugs."
@spec load(String.t()) :: %{tags: [tag_entry()], templates: [String.t()]}
def load(project_id) do
counts = tag_counts(project_id)
tags =
project_id
|> Tags.list_tags()
|> Enum.map(fn tag ->
%{
id: tag.id,
name: tag.name,
color: tag.color,
post_template_slug: tag.post_template_slug,
count: Map.get(counts, tag.name, 0)
}
end)
%{tags: tags, templates: template_slugs(project_id)}
end
@doc "How many posts use each tag name."
@spec tag_counts(String.t()) :: %{String.t() => non_neg_integer()}
def tag_counts(project_id) do
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
@spec post_count(String.t(), String.t()) :: non_neg_integer()
def post_count(project_id, tag_name), do: Map.get(tag_counts(project_id), tag_name, 0)
@spec create(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def create(project_id, name) do
case Tags.create_tag(%{project_id: project_id, name: name}) do
{:ok, tag} -> {:ok, dgettext("ui", "Tag %{name} created", name: tag.name)}
{:error, reason} -> {:error, error_message(reason)}
end
end
@spec rename(String.t(), String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def rename(project_id, tag_name, new_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
normalized = String.trim(to_string(new_name || ""))
cond do
normalized == "" ->
{:error, dgettext("ui", "Tag name cannot be empty")}
normalized == tag.name ->
{:ok, dgettext("ui", "Tag unchanged")}
true ->
case Tags.rename_tag(tag.id, normalized) do
{:ok, renamed} ->
{:ok, dgettext("ui", "Tag renamed to %{name}", name: renamed.name)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
end
@doc "Advances the tag colour through the presets (last preset wraps to none)."
@spec cycle_color(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def cycle_color(project_id, tag_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
next = next_in_cycle(@colour_presets, tag.color)
case Tags.update_tag(tag.id, %{color: next}) do
{:ok, updated} ->
{:ok,
dgettext("ui", "Colour of %{name}: %{color}",
name: updated.name,
color: updated.color || dgettext("ui", "none")
)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
@doc "Advances the tag post template through the project templates (wraps to none)."
@spec cycle_template(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def cycle_template(project_id, tag_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
next = next_in_cycle(template_slugs(project_id), tag.post_template_slug)
case Tags.update_tag(tag.id, %{post_template_slug: next}) do
{:ok, updated} ->
{:ok,
dgettext("ui", "Template of %{name}: %{template}",
name: updated.name,
template: updated.post_template_slug || dgettext("ui", "default")
)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
@spec delete(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def delete(project_id, tag_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
case Tags.delete_tag(tag.id) do
{:ok, _deleted} -> {:ok, dgettext("ui", "Tag %{name} deleted", name: tag_name)}
{:error, reason} -> {:error, error_message(reason)}
end
end
end
@doc "Merges the named tags into `target_name` (which must be one of them)."
@spec merge(String.t(), [String.t()], String.t()) :: {:ok, String.t()} | {:error, String.t()}
def merge(project_id, source_names, target_name) do
tags =
Repo.all(
from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^source_names
)
target = Enum.find(tags, &(&1.name == target_name))
sources = Enum.reject(tags, &(&1.name == target_name))
cond do
target == nil ->
{:error, dgettext("ui", "The merge target must be one of the marked tags")}
sources == [] ->
{:error, dgettext("ui", "Mark at least two tags to merge")}
true ->
case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do
{:ok, _merged} ->
{:ok,
dgettext("ui", "Merged %{count} tags into %{name}",
count: length(sources),
name: target.name
)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
@spec sync(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def sync(project_id) do
case Tags.sync_tags_from_posts(project_id) do
{:ok, tags} ->
{:ok, dgettext("ui", "Tags synced from posts (%{count} total)", count: length(tags))}
{:error, reason} ->
{:error, error_message(reason)}
end
end
defp fetch(project_id, tag_name) do
case Repo.get_by(Tag, project_id: project_id, name: tag_name) do
nil -> {:error, dgettext("ui", "Tag %{name} not found", name: tag_name)}
tag -> {:ok, tag}
end
end
defp template_slugs(project_id) do
Repo.all(
from template in Template,
where: template.project_id == ^project_id,
order_by: [asc: template.slug],
select: template.slug
)
end
# nil → first option → … → last option → nil
defp next_in_cycle([], _current), do: nil
defp next_in_cycle(options, current) do
case Enum.find_index(options, &(&1 == current)) do
nil -> List.first(options)
index when index == length(options) - 1 -> nil
index -> Enum.at(options, index + 1)
end
end
defp error_message(reason) when is_binary(reason), do: reason
defp error_message(:not_found), do: dgettext("ui", "Tag not found")
defp error_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 error_message(reason), do: inspect(reason)
end

View File

@@ -0,0 +1,91 @@
defmodule BDS.UI.TaskGrouping do
@moduledoc """
Groups task snapshot entries for the bottom panel's Tasks tab, mirroring the
old app's `taskGrouping.ts`: tasks sharing a `group_id` collapse into one
group entry (ordered by first appearance), everything else stays a single
entry. Groups summarize to per-status counts plus an aggregate progress in
the 0.0..1.0 range.
"""
@type task :: map()
@type entry :: {:single, task()} | {:group, String.t(), String.t(), [task()]}
@type summary :: %{
total: non_neg_integer(),
running: non_neg_integer(),
pending: non_neg_integer(),
completed: non_neg_integer(),
failed: non_neg_integer(),
cancelled: non_neg_integer(),
progress: float()
}
@spec build_task_entries([task()]) :: [entry()]
def build_task_entries(tasks) when is_list(tasks) do
{singles, groups} =
tasks
|> Enum.with_index()
|> Enum.reduce({[], %{}}, fn {task, index}, {singles, groups} ->
case Map.get(task, :group_id) do
nil ->
{[{index, task} | singles], groups}
group_id ->
groups =
Map.update(groups, group_id, {index, Map.get(task, :group_name) || group_id, [task]}, fn {first_index, name, grouped} ->
{first_index, name, [task | grouped]}
end)
{singles, groups}
end
end)
single_entries = Enum.map(singles, fn {index, task} -> {index, {:single, task}} end)
group_entries =
Enum.map(groups, fn {group_id, {index, name, grouped}} ->
{index, {:group, group_id, name, Enum.reverse(grouped)}}
end)
(single_entries ++ group_entries)
|> Enum.sort_by(fn {index, _entry} -> index end)
|> Enum.map(fn {_index, entry} -> entry end)
end
@spec summarize_task_group([task()]) :: summary()
def summarize_task_group(tasks) when is_list(tasks) do
base = %{total: 0, running: 0, pending: 0, completed: 0, failed: 0, cancelled: 0, progress: 0.0}
summary =
Enum.reduce(tasks, base, fn task, acc ->
status = Map.get(task, :status)
acc
|> Map.update!(:total, &(&1 + 1))
|> count_status(status)
|> Map.update!(:progress, &(&1 + progress_contribution(task)))
end)
if summary.total > 0 do
%{summary | progress: summary.progress / summary.total}
else
summary
end
end
defp count_status(acc, status) when status in [:running, :pending, :completed, :failed, :cancelled],
do: Map.update!(acc, status, &(&1 + 1))
defp count_status(acc, _status), do: acc
# Finished tasks count as fully done no matter their outcome, pending tasks
# contribute nothing yet (old-app getProgressContribution).
defp progress_contribution(%{status: status}) when status in [:completed, :failed, :cancelled],
do: 1.0
defp progress_contribution(%{status: :pending}), do: 0.0
defp progress_contribution(%{progress: progress}) when is_number(progress),
do: progress |> max(0.0) |> min(1.0)
defp progress_contribution(_task), do: 0.0
end

38
mix.exs
View File

@@ -2,6 +2,8 @@ defmodule BDS.MixProject do
use Mix.Project
def project do
ensure_xla_compiler_flags()
[
app: :bds,
version: "0.1.0",
@@ -15,6 +17,22 @@ defmodule BDS.MixProject do
]
end
# XLA 0.9.x headers (pulled by exla) illegally specialize `std::is_signed`,
# which Apple clang 17+/libc++ rejects with a hard `-Winvalid-specialization`
# error, breaking the exla NIF build. Disable just that diagnostic on macOS so
# the C++ compile succeeds. Guarded to Darwin so the Linux/gcc build path is
# untouched. Appends rather than overwrites any CFLAGS the caller already set.
defp ensure_xla_compiler_flags do
if :os.type() == {:unix, :darwin} do
flag = "-Wno-invalid-specialization"
current = System.get_env("CFLAGS", "")
unless String.contains?(current, flag) do
System.put_env("CFLAGS", String.trim(flag <> " " <> current))
end
end
end
def application do
[
extra_applications: [:logger, :wx, :ssl],
@@ -32,6 +50,7 @@ defmodule BDS.MixProject do
{:ecto_sqlite3, "~> 0.24"},
{:luerl, "~> 1.5"},
{:jason, "~> 1.4"},
{:optimus, "~> 0.5"},
{:mdex, "~> 0.13"},
{:liquex, "~> 0.13.1"},
{:plug, "~> 1.18"},
@@ -40,13 +59,24 @@ defmodule BDS.MixProject do
{:live_toast, "~> 0.8.0"},
{:saxy, "~> 1.4"},
{:desktop, "~> 1.5"},
{:ex_ratatui, "~> 0.11"},
{:image, "~> 0.67"},
{:nx, "~> 0.10"},
{:exla, "~> 0.10"},
# Only one ML inference backend ships per platform; NIF releases are
# host-built, so the build OS is the target OS. EXLA (283 MB XLA CPU
# payload) never runs in the macOS app — EMLX always wins on Apple
# Silicon — but `runtime: false` alone can't exclude it there because
# the `image` dep declares exla as an optional application, which drags
# it into the release whenever it exists in the prod dep tree. Scoping
# it to dev/test on macOS keeps it fully out of macOS releases while
# Linux/Windows ship it as their production inference backend.
{:exla, "~> 0.10", if(macos?(), do: [only: [:dev, :test]], else: [])},
# Apple Silicon GPU (Metal) acceleration for embedding inference. Ships
# precompiled MLX binaries; the Neural backend prefers it on arm64 macOS
# and falls back to EXLA-CPU elsewhere (SPECGAPS A1-14c).
{:emlx, "~> 0.2.0"},
# and falls back to EXLA-CPU elsewhere (SPECGAPS A1-14c). Apple-only, so
# non-macOS releases exclude it (runtime: false deps stay out of
# releases — nothing pulls emlx in optionally, unlike exla above).
{:emlx, "~> 0.2.0", runtime: macos?()},
{:bumblebee, "~> 0.6.3"},
{:hnswlib, "~> 0.1.7"},
{:stemex, "~> 0.2.1"},
@@ -101,4 +131,6 @@ defmodule BDS.MixProject do
]
]
end
defp macos?, do: :os.type() == {:unix, :darwin}
end

View File

@@ -25,6 +25,7 @@
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
"ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"},
"ex_ratatui": {:hex, :ex_ratatui, "0.11.1", "f3cc5c9827d957bc326d3b3b48338a927716fedc2575311d65a3af0dd03ed951", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b37b02b33e1e8f49874497d7b7f8872417b3807ec77d4bc1526c07c05c7e4cc9"},
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
"exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"},
@@ -61,6 +62,7 @@
"nx_image": {:hex, :nx_image, "0.1.2", "0c6e3453c1dc30fc80c723a54861204304cebc8a89ed3b806b972c73ee5d119d", [:mix], [{:nx, "~> 0.4", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "9161863c42405ddccb6dbbbeae078ad23e30201509cc804b3b3a7c9e98764b81"},
"nx_signal": {:hex, :nx_signal, "0.2.0", "e1ca0318877b17c81ce8906329f5125f1e2361e4c4235a5baac8a95ee88ea98e", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "7247e5e18a177a59c4cb5355952900c62fdeadeb2bad02a9a34237b68744e2bb"},
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
"optimus": {:hex, :optimus, "0.6.1", "b493bdc7ee71035fdf8cbfbf158b1863e2d622b65c3906153e4042815c901fca", [:mix], [], "hexpm", "c0db4107a51f5af94de8b05e4208333ebb8016a3bfdbcd74df6e5c99829db17f"},
"phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566
#: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archiv"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566
#: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566
#: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archivo"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566
#: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archives"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566
#: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archivio"

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ msgstr ""
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:566
#: lib/bds/ui/sidebar.ex:586
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr ""

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

23
rel/overlays/cli/bin/bds-cli Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/sh
# bds-cli: workspace CLI for bDS2 (issue #25). Runs commands inside the
# release VM against the same settings and cache database as the app.
set -e
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
RELEASE_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
export RELEASE_ROOT
# The TUI owns the terminal, so it boots the app instead of the one-shot CLI.
if [ "$1" = "tui" ]; then
BDS_MODE=tui exec "$RELEASE_ROOT/bin/bds" start
fi
# `bin/bds eval` cannot forward arguments, so the argv travels in an
# environment variable, joined with the ASCII unit separator (0x1f).
US="$(printf '\037')"
ARGV=""
for arg in "$@"; do
ARGV="${ARGV}${arg}${US}"
done
BDS_CLI_ARGV="$ARGV" BDS_MODE=cli exec "$RELEASE_ROOT/bin/bds" eval "BDS.CLI.main()"

View File

@@ -47,6 +47,9 @@ for await (const line of rl) {
assistant_visible: !hasClass("[data-testid='assistant-shell']", "is-hidden"),
assistant_width: document.querySelector("[data-testid='assistant-shell']")?.getBoundingClientRect().width ?? 0,
panel_visible: !hasClass(".panel-shell", "is-hidden"),
panel_tabs_height: document.querySelector(".panel-tabs")?.getBoundingClientRect().height ?? 0,
panel_tab_labels: texts(".panel-tab", (node) => node.textContent.trim()),
panel_active_tab: text(".panel-tab.active"),
editor_title: text("[data-testid='editor-title']"),
activity_labels: texts("[data-testid='activity-button']", (node) => node.getAttribute("aria-label")),
sidebar_sections: texts("[data-testid='sidebar-section-title']", (node) => node.textContent.trim()),

175
specs/cli.allium Normal file
View File

@@ -0,0 +1,175 @@
-- allium: 1
-- Workspace CLI tool (issue #25)
-- Scope: extension (Bucket G — MCP + Automation)
-- Distilled from: lib/bds/cli.ex, lib/bds/cli/commands.ex, lib/bds/cli/install.ex,
-- rel/overlays/cli/bin/bds-cli
entity CliInvocation {
command: rebuild | repair | render | upload | push | pull | post | media
| gallery | config | project | tui | lua
incremental: Boolean
force: Boolean
exit_code: Integer
-- Parsed by Optimus: subcommands, options, flags, and auto-generated
-- help/version output. Unknown commands and invalid options exit 1
-- with formatted errors on stderr.
}
surface CliSurface {
facing _: CliRuntime
provides:
CliCommandExecuted(command)
CliInstallRequested()
}
invariant SharedDatabase {
-- The CLI boots the application in BDS_MODE=cli: the same repo,
-- settings, and cache database as the GUI/TUI app — but with no
-- HTTP listener, no SSH daemon, no window, and no sync watcher.
-- Console logging is redirected to the rotating log file so stdout
-- carries only command output.
}
invariant CliWritesNotify {
-- Every CLI mutation (post/media/gallery creation, config set,
-- project add/switch, bulk rebuild/repair) inserts DbNotification
-- rows (from_cli: true) so a concurrently running app picks the
-- change up through its sync watcher (see cli_sync.allium). Bulk
-- maintenance uses wildcard entity ids per entity type.
}
rule ExitCode {
when: CliCommandExecuted(command)
-- Success prints the result (stdout) and exits 0; any error prints
-- to stderr and exits 1.
requires: CliInvocation.command = command
ensures: CliInvocation.exit_code.updated()
}
rule RebuildFull {
when: CliCommandExecuted(command: rebuild)
requires: not CliInvocation.incremental
-- The same step sequence as the GUI "Rebuild Database"
-- (BDS.Maintenance.full_rebuild_steps — posts, media, scripts,
-- templates, post links, thumbnails, embedding index), run
-- synchronously with progress on stdout.
ensures: CacheDatabaseRebuilt()
}
rule RebuildIncremental {
when: CliCommandExecuted(command: rebuild)
requires: CliInvocation.incremental
-- Metadata diff, then auto-apply file→db for every difference and
-- import every orphan file.
ensures: CacheDatabaseRebuilt()
}
rule Repair {
when: CliCommandExecuted(command: repair)
-- Subcommand argument selects the repair part: post-links,
-- media-links, thumbnails, embeddings, or search — the standard
-- rebuild tasks outside the full rebuild.
ensures: RepairTaskCompleted()
}
rule Render {
when: CliCommandExecuted(command: render)
-- Default: render all site sections plus the search index.
-- --incremental: validate the generated output and apply only the
-- differences (targeted render + extra-file deletion + calendar).
-- --force: full re-render ignoring (but updating) content hashes.
requires: not (CliInvocation.incremental and CliInvocation.force)
ensures: SiteRendered()
}
rule Upload {
when: CliCommandExecuted(command: upload)
-- Uses the project publishing preferences (ssh host/user/path/mode)
-- exactly like the app's upload, and waits for the publish job.
ensures: SiteUploaded()
}
rule GitSync {
when: CliCommandExecuted(command: push | pull)
-- push: git push of the project repository to origin.
-- pull: git pull --ff-only, then the incremental cache update
-- (metadata diff auto-apply + orphan import) so the database
-- reflects the pulled files.
ensures: RepositorySynchronized()
}
rule CreatePost {
when: CliCommandExecuted(command: post)
-- Post data from parameters or JSON on stdin (LLM-friendly).
-- Language is auto-detected when missing: the configured AI
-- endpoint (airplane mode routes to the local model), falling back
-- to the offline heuristic with a printed notice — the CLI
-- equivalent of the airplane-mode toast. After creation the same
-- auto-translation as the GUI is scheduled and awaited; when
-- nothing can be scheduled the user is told.
ensures: PostCreated()
}
rule CreateMedia {
when: CliCommandExecuted(command: media)
-- Imports the image, then best-effort AI enrichment: generated
-- title, alt text, caption, and translations to all configured
-- blog languages (the gallery pipeline without post linking).
ensures: MediaImported()
}
rule CreateGallery {
when: CliCommandExecuted(command: gallery)
-- Creates the post, then runs the shared gallery import pipeline
-- for every referenced image: import, mandatory post link, AI
-- enrichment, translations; finally the post auto-translation.
ensures: PostCreated()
ensures: MediaImported()
}
rule Preferences {
when: CliCommandExecuted(command: config)
-- get/set/list of the global settings store shared with the app.
ensures: PreferencesAccessed()
}
rule Projects {
when: CliCommandExecuted(command: project)
-- list, add <path> (register a folder in the cache database), and
-- switch <id|slug|name> (change the active project).
ensures: ProjectRegistryUpdated()
}
rule Tui {
when: CliCommandExecuted(command: tui)
-- Handled by the launcher script: exec the release in BDS_MODE=tui
-- so the interactive terminal UI owns the terminal.
ensures: TuiStarted()
}
rule RunLuaTask {
when: CliCommandExecuted(command: lua)
-- Runs a utility ("task", long-running) script from the database by
-- slug in the active project, with the managed-job execution budget
-- (unlimited time/reductions) but synchronously. Macro and
-- transform scripts are rejected.
ensures: ScriptExecuted()
}
rule InstallLauncher {
when: CliInstallRequested()
-- Install buttons in the GUI settings (Data section) and the TUI
-- settings (data form action field) both call
-- BDS.UI.SettingsForm.run_action("install_cli"): a shim exec'ing
-- the release's cli/bin/bds-cli launcher is written to
-- ~/.local/bin/bds-cli. Outside a packaged release the action
-- reports that installing requires the packaged application.
ensures: LauncherInstalled()
}
invariant LauncherArgv {
-- bin/bds eval cannot forward arguments, so the launcher passes the
-- argv in BDS_CLI_ARGV joined with the ASCII unit separator (0x1f)
-- and the release evaluates BDS.CLI.main().
}

View File

@@ -241,7 +241,12 @@ invariant NativeAcceleratedExecution {
-- On Apple Silicon the model runs on the Apple GPU via EMLX (MLX/Metal,
-- params placed on the EMLX.Backend GPU device); everywhere else, and as a
-- fallback when EMLX is unavailable, it runs on optimised native CPU via
-- EXLA. Selection is `accelerator: :auto | :emlx | :exla` (default :auto).
-- EXLA. Selection is `accelerator: :auto | :emlx | :exla` (default :auto);
-- a request for an unavailable backend degrades to the other one.
-- Packaged releases ship only the backend their platform can run (runtime:
-- conditional deps): macOS releases exclude EXLA, Linux/Windows releases
-- exclude the Apple-only EMLX. The selected backend is started on demand
-- by the Neural backend, never assumed started at boot.
-- Neighbour search is HNSW (hnswlib).
}

59
specs/events.allium Normal file
View File

@@ -0,0 +1,59 @@
-- allium: 1
-- Domain Event Bus (issue #26, phase 2)
-- Scope: extension (multi-client synchronization over Phoenix.PubSub)
-- Distilled from: lib/bds/events.ex, context broadcast call sites,
-- lib/bds/desktop/shell_live.ex, lib/bds/cli_sync/watcher.ex
entity EntityChangedEvent {
entity: String -- post, media, tag, template, script
entity_id: String
action: created | updated | deleted
-- Same topic and payload shape as the CLI sync watcher, so one
-- subscription covers in-app mutations and external CLI writes.
}
entity SettingsChangedEvent {
key: String -- e.g. ui.language
}
surface EventBusSurface {
facing _: EventBus
provides:
ContextMutationSucceeded(entity, entity_id, action)
GlobalSettingWritten(key)
ClientSubscribed()
}
rule BroadcastEntityMutation {
when: ContextMutationSucceeded(entity, entity_id, action)
-- Posts, Media, Tags, Templates, Scripts broadcast after every
-- successful create/update/publish/delete (publish maps to updated).
ensures: EntityChangedEvent.created(
entity: entity,
entity_id: entity_id,
action: action
)
}
rule BroadcastSettingsChange {
when: GlobalSettingWritten(key)
ensures: SettingsChangedEvent.created(key: key)
}
rule ClientSynchronization {
when: ClientSubscribed()
-- Every shell (LiveView GUI or TUI session) subscribes on mount and
-- refreshes affected views on each event; deleted entities close
-- their open tabs. This keeps all connected clients synchronized
-- regardless of which client or pipeline originated the change.
ensures: ClientRefreshOnEvent()
}
rule ServerSideUiLanguage {
when: GlobalSettingWritten(key: "ui.language")
-- The UI language is a single server-side setting: changing it in any
-- client persists it and re-renders every connected client. The OS
-- locale is only the fallback when the setting is unset.
ensures: AllClientsRelocalized()
}

View File

@@ -113,6 +113,17 @@ invariant MultiLanguageRoutes {
-- Each language subtree gets its own feeds and archives
}
invariant LanguageVariantSelection {
-- Every language tree renders each post in its own language when a
-- matching variant exists, falling back to the post's original language.
-- This applies uniformly to single pages, list pages (home/pagination,
-- category/tag/date archives), and feeds:
-- Main tree: a post whose language differs from main_language renders
-- its main_language translation when one exists (canonical variant).
-- Additional-language trees: posts resolve to that language's
-- translation; do_not_translate posts are excluded from those trees.
}
invariant CanonicalBaseUrlConfigured {
for generation in SiteGenerations:
generation.base_url != ""

View File

@@ -249,6 +249,8 @@ invariant PanelTabFallback {
-- Tasks tab: last 10 tasks, newest first, with progress/cancel.
-- Tasks with shared group_id are collapsible groups showing aggregate progress.
-- Output tab: log entries with copy-all button.
-- The panel opens on Output automatically when a blogmark transform fails
-- or a transform script writes output.
-- Post Links tab: backlinks (posts linking here) + outlinks (posts linked from here).
-- Each entry clickable, opens linked post as pinned tab.
-- Git Log tab: file-level git history for active post/media (up to 50 entries).

View File

@@ -110,6 +110,12 @@ surface ScriptRuntimeSurface {
-- Progress reporting is cooperative and flows through the supplied
-- progress sink rather than ambient global side effects.
@guarantee ScriptOutputRouting
-- print(…) calls and the explicit bds.app.log(…) API append lines to a
-- host-routed script output stream: the desktop app's Output panel,
-- stdout in the CLI, and the host logger when no sink is attached —
-- never the ambient console.
@guarantee BatchCancellation
-- Managed utility and transform jobs can be cancelled by the host
-- operator boundary.

90
specs/server.allium Normal file
View File

@@ -0,0 +1,90 @@
-- allium: 1
-- Headless Server Mode (issue #26, phase 1)
-- Scope: extension (client/server split — SSH transport for TUI and GUI)
-- Distilled from: lib/bds/server.ex, lib/bds/application.ex, lib/bds/tui.ex
entity BootMode {
kind: desktop | server | tui
-- Resolved from the BDS_MODE environment variable at application start.
-- "server" is headless; "tui" is the headless server plus a TUI on the
-- launching terminal; anything else is desktop.
}
entity SshKeyMaterial {
dir: String -- <data dir>/ssh, next to the database
host_key: String -- ssh_host_rsa_key, generated on first boot
authorized_keys: String -- created empty (mode 600) on first boot
-- Invariant: key material lives in the private app data dir,
-- never in the repo or an application priv dir.
}
surface ServerRuntimeSurface {
facing _: ServerRuntime
provides:
ApplicationStarted(mode)
SshClientConnected()
GuiConnectRequested()
}
rule ModeResolution {
when: ApplicationStarted(mode)
-- BDS_MODE environment variable decides the mode once per boot.
-- Fallback (issue #33): resolved desktop mode boots as tui when no
-- graphical session is available, so the app always opens a UI
-- rather than crashing wx; a log line records the switch. Headless
-- means: non-Darwin unix without DISPLAY/WAYLAND_DISPLAY (plain ssh
-- included; ssh -X with a forwarded display keeps the GUI), or macOS
-- inside an ssh session (SSH_CONNECTION/SSH_CLIENT/SSH_TTY — macOS
-- never sets DISPLAY). Explicit server/tui modes and Windows are
-- never rewritten. Because the :desktop dependency boots wx in its
-- own application start, config/runtime.exs sets NO_WX=1 for every
-- non-desktop effective mode (BDS.Server.prepare_boot_env) so the
-- dependency starts inert before any BDS supervisor runs. In tui
-- mode prepare_boot_env also silences every other terminal writer —
-- the default logger handler moves to a rotating file in the private
-- app dir and Bumblebee's model-download progress bar is disabled —
-- because the TUI owns the terminal and each stray console line
-- scrolls the alternate screen up one row. Desktop/server modes and
-- SSH-served TUI sessions keep normal console logging.
ensures: BootMode.created(kind: mode)
}
rule DesktopBoot {
when: ApplicationStarted(mode: desktop)
-- Unchanged behavior: wx window shell, loopback HTTP endpoint.
ensures: DesktopWindowStarted()
}
rule ServerBoot {
when: ApplicationStarted(mode: server)
-- Headless: no wx children at all. Works on macOS, Windows, Linux.
ensures: LoopbackEndpointStarted()
-- HTTP endpoint stays bound to 127.0.0.1; never exposed directly
ensures: CliSyncWatcherStarted()
ensures: SshDaemonStarted()
-- ExRatatui.SSH.Daemon: public-key auth only (no passwords),
-- tcpip_tunnel_out enabled so GUI clients tunnel to the loopback
-- endpoint through the same SSH connection and the same keys
ensures: SshKeyMaterial.created()
-- host key generated and authorized_keys touched on first boot only
}
rule GuiRemoteConnection {
when: GuiConnectRequested()
-- Desktop "Connect to Server…": ssh connect with the same public keys
-- (client identity + known_hosts in the private ssh dir), then an OTP
-- TCP/IP tunnel to the server's loopback endpoint; the webview loads
-- the local tunnel end. Disconnect returns to the local shell URL.
ensures: WebviewTunneledToServer()
}
rule TuiSession {
when: SshClientConnected()
-- Each SSH client gets its own TUI app instance on the server;
-- only terminal cells cross the wire.
ensures: TuiAppMounted()
-- UI locale comes from the server-side UI language setting,
-- identical for every client
}

211
specs/tui.allium Normal file
View File

@@ -0,0 +1,211 @@
-- allium: 1
-- Terminal UI (issue #26, phase 4)
-- Scope: extension (second renderer over the shared UI core)
-- Distilled from: lib/bds/tui.ex, lib/bds/ui/sidebar.ex, lib/bds/ui/post_editor/*.ex
entity TuiState {
view: posts | media | templates | scripts | tags | settings | git
focus: sidebar | editor
selected_index: Integer
editing_post: Boolean
-- The editor drives the same BDS.UI.PostEditor workflow as the GUI:
-- canonical-language edits update the post, other languages update
-- translations; publish routes through the same publishing pipeline.
}
surface TuiSurface {
facing _: TuiRuntime
provides:
TuiKeyPressed(code, modifiers)
TuiEventReceived(entity, entity_id, action)
TuiSettingsChanged(key)
ShellTaskCompleted(route)
}
rule SidebarNavigation {
when: TuiKeyPressed(code: "up" | "down" | "j" | "k")
-- Selection moves over the flattened sidebar items and skips
-- section headers; data comes from BDS.UI.Sidebar.view, identical
-- to the GUI sidebar content.
ensures: TuiState.selected_index.updated()
}
rule OpenEntry {
when: TuiKeyPressed(code: "enter")
-- Posts open in the editor by default (title + textarea seeded from
-- the persisted form; syntax highlighting and word wrap on; ctrl+e
-- switches to the rendered preview). New empty posts created with
-- "n" open in the editor as well. Images open in the terminal image
-- preview. Other entities report that terminal editing is not
-- available yet.
ensures: TuiState.editing_post.updated()
}
rule SaveAndPublish {
when: TuiKeyPressed(code: "s" | "p", modifiers: "ctrl")
-- ctrl+s saves the draft, ctrl+p saves and publishes — both through
-- BDS.UI.PostEditor.Persistence, so files, embeddings, search, and
-- the event bus behave exactly as a GUI save.
ensures: PostPersisted()
}
rule EditorMode {
when: TuiKeyPressed(code: "e", modifiers: "ctrl")
-- ctrl+e toggles between the syntax-highlighted editor (the default
-- mode when opening a post) and the read-only rendered-Markdown
-- preview (rendered through tui-markdown so headings/emphasis/lists
-- are styled); keys in preview never edit content, and esc returns
-- to the sidebar from either mode.
ensures: PreviewToggled()
}
rule CodeEditorWrapping {
when: TuiState.editing_post.updated()
-- The post editor renders the buffer with syntax highlighting
-- (markdown with [[macro]] accents for posts, HTML+Liquid for
-- templates, Lua for scripts), soft-wraps long lines to the
-- viewport width, keeps the cursor's visual row visible while
-- scrolling, and highlights the cursor's source line. There is no
-- line-number gutter.
ensures: TuiState.updated()
}
rule AiQuickAction {
when: TuiKeyPressed(code: "g", modifiers: "ctrl")
-- One-shot AI post analysis (title/excerpt suggestions). Gated by
-- airplane mode: without a local endpoint the TUI shows a status
-- message instead of calling out.
ensures: AiSuggestionsRequestedOrRefused()
}
rule ProjectSwitcher {
when: TuiKeyPressed(code: "p")
-- "p" opens the projects overlay: all projects from the caching
-- database with the active one marked; enter activates the selected
-- project (BDS.Projects.set_active_project) and reloads the sidebar.
-- "o" switches to a folder-path prompt with bash-style tab
-- completion: tab completes a unique directory (trailing slash
-- appended), an ambiguous prefix completes to the longest common
-- prefix and lists the candidate folders; only directories are
-- offered, dotfolders only for a dot-prefixed input, and a leading
-- "~" expands to the home directory. A typed path is validated
-- to be an existing directory, then opened as a project
-- (BDS.Projects.create_project with data_path, named after the
-- folder) and activated; a full database rebuild is queued
-- automatically through the rebuild_database shell command so the
-- folder's content is loaded into the caching database. Invalid
-- paths keep the prompt open and report the error.
ensures: ProjectSwitchedOrOpened()
}
rule SidebarSearch {
when: TuiKeyPressed(code: "/")
-- "/" in sidebar focus opens a vi-style search prompt in the status
-- line that live-filters the current view through the same
-- BDS.UI.Sidebar filter params the GUI sidebar uses (posts, pages,
-- media). Plain words are a text search, "tag:x" and "category:x"
-- filter like the GUI chips, and an ISO date (yyyy-mm-dd) narrows
-- to that day — the day filter extends the shared sidebar queries.
-- Tokens combine with AND. Enter keeps the filter (shown appended
-- to the sidebar title), esc clears it; filters are per view.
ensures: SidebarFiltered()
}
rule CommandPrompt {
when: TuiKeyPressed(code: ":")
-- Vi-style prompt: ":" opens a filterable list of the parameterless
-- Blog-menu shell commands (metadata diff, validate site, force
-- render, rebuilds, reindex, translations, duplicates, upload,
-- browser preview URL) and runs the selection through the same
-- BDS.Desktop.ShellCommands backend as the GUI menu; typing ":?"
-- shows the full list as help. Overlays clear the cells beneath
-- them. Commands whose follow-up UI is GUI-only (validate
-- translations, find duplicates) are marked. Queued tasks report
-- progress in the status line until all tasks finish.
ensures: CommandListShownOrExecuted()
}
rule SettingsPanel {
when: TuiKeyPressed(code: "6")
-- "6" opens the settings panel (issue #29), matching the GUI panel
-- numbering: the sidebar lists the same preference sections as the
-- GUI settings editor (Project, Editor, Content, AI, Technology,
-- Publishing, Data, MCP, Style — from BDS.UI.Sidebar's settings
-- view). Enter on a section opens a section-specific editor in the
-- main area: a generic typed-field form from BDS.UI.SettingsForm.
-- Enter edits a text field in a status-line prompt, toggles a
-- boolean, or cycles an enum through its options; read-only info
-- rows are skipped by the selection. ctrl+s saves the section
-- through the same backends as the GUI editor (BDS.Metadata,
-- BDS.Settings, BDS.AI, BDS.MCP.AgentConfig) and reloads the form;
-- esc cancels the prompt, then closes the form. Category removal
-- and AI model discovery remain GUI-only.
ensures: SettingsFormShownEditedOrSaved()
}
rule TagsPanel {
when: TuiKeyPressed(code: "5")
-- "5" opens the tags view (issue #34), matching the GUI panel
-- numbering. The sidebar lists the same three sections as the GUI
-- tags editor (Tag Cloud, Create / Edit, Merge Tags — from
-- BDS.UI.Sidebar's tags nav view); enter opens the section in the
-- main area, all backed by BDS.UI.TagsPanel over the same BDS.Tags
-- operations as the GUI editor. Cloud lists tags with their post
-- usage counts ordered by usage; manage is alphabetical with "n"
-- (create prompt), enter (rename prompt), "c" (cycle colour through
-- the shared presets), "t" (cycle post template), "d" then "y"
-- (delete with post-count confirmation; any other key cancels) and
-- "s" (sync tags from post tags); merge marks tags with space and
-- "m" merges the marked tags into the selected one (at least two,
-- target included). Text prompts live in the status line; esc
-- cancels the prompt, then closes the panel. Domain tag events
-- reload an open panel, keeping it in sync with GUI and CLI writes.
ensures: TagsPanelShownOrEdited()
}
rule GitPanel {
when: TuiKeyPressed(code: "7")
-- "7" opens the git panel (issue #30) for content sync: the sidebar
-- lists the changed files from git status (code + path, branch with
-- ahead/behind counts in the title) with the commit history in its
-- lower half, like the GUI (short hash + subject; ↑ marks commits
-- that still need a push, ↓ remote-only ones), the main area shows a
-- scrollable whole-folder diff (staged + unstaged, capped) paged
-- with pgup/pgdn; enter on a file jumps the diff to it. "c" opens a
-- status-line commit prompt (git add -A + commit, empty messages
-- rejected), "u" pulls (ff-only) and "s" pushes — both run off the
-- UI process and report back as a status toast. Every action is
-- BDS.Git, the same backend as the GUI git panel. A project folder
-- that is not a git repository shows a message and refuses the
-- actions.
ensures: GitPanelShownOrSynced()
}
rule ReportPanels {
when: ShellTaskCompleted(route: "metadata_diff" | "site_validation")
-- Completed metadata diff and site validation tasks open a
-- scrollable report panel showing the differences. Enter applies
-- the whole report — metadata diff repairs all items from the
-- filesystem (file → db) and imports orphan files; site validation
-- applies the full report incrementally (render missing, remove
-- extra, re-render updated), matching the GUI defaults. Esc closes
-- the report without applying. Reports completed before this
-- session started never pop up.
ensures: ReportShownThenAppliedOrCancelled()
}
rule MultiClientSync {
when: TuiEventReceived(entity, entity_id, action)
-- Domain events refresh the sidebar; a post deleted elsewhere closes
-- the open editor. Keeps TUI sessions synchronized with GUI clients
-- and pipeline ingestion.
ensures: TuiState.updated()
}
rule ServerSideLocale {
when: TuiSettingsChanged(key: "ui.language")
-- The TUI re-reads the server-side UI language and re-renders; all
-- clients always share one language.
ensures: TuiRelocalized()
}

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