Compare commits
34 Commits
6ec61610f1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 655069a28a | |||
| 593f1646a1 | |||
| c77450d497 | |||
| 23f05b72f7 | |||
| dd53ca3fbc | |||
| 6c9dd9605b | |||
| 46c239df56 | |||
| 2fd132e827 | |||
| 0f3f1efa08 | |||
| e4fa61ae07 | |||
| 8fe18d108f | |||
| b517672663 | |||
| b016f6d812 | |||
| 505527ae4f | |||
| b6b1d16e54 | |||
| 46fd6c1b85 | |||
| 5433cb59ac | |||
| e027364c0e | |||
| 3675a26407 | |||
| 9a1f301527 | |||
| 59333ac920 | |||
| 381deb417d | |||
| 5801e49dc1 | |||
| a8ca3b643b | |||
| 39bb232b57 | |||
| 824313f106 | |||
| 01b90e00a0 | |||
| c77686250e | |||
| 48e0903542 | |||
| df2bdc2041 | |||
| a3ca44640c | |||
| 3a8fafd2fd | |||
| 16f45d77b2 | |||
| 700ed79c43 |
@@ -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
29
API.md
@@ -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**
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
65
README.md
65
README.md
@@ -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
223
TUI_EDITOR.md
Normal 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.
|
||||
@@ -49,8 +49,7 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-toolbar-button:hover,
|
||||
.panel-tab:hover {
|
||||
.editor-toolbar-button:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
27
assets/js/utils/clipboard.js
Normal file
27
assets/js/utils/clipboard.js
Normal 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);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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
335
lib/bds/cli.ex
Normal 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
696
lib/bds/cli/commands.ex
Normal 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, ¬ify(&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
57
lib/bds/cli/install.ex
Normal 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
|
||||
52
lib/bds/desktop/dialogs.ex
Normal file
52
lib/bds/desktop/dialogs.ex
Normal 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
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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")
|
||||
|
||||
180
lib/bds/desktop/remote_connection.ex
Normal file
180
lib/bds/desktop/remote_connection.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
58
lib/bds/events.ex
Normal 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
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} ->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
221
lib/bds/server.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
2174
lib/bds/tui.ex
Normal file
File diff suppressed because it is too large
Load Diff
72
lib/bds/ui/code_editor/highlight.ex
Normal file
72
lib/bds/ui/code_editor/highlight.ex
Normal 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
|
||||
225
lib/bds/ui/code_editor/overlay.ex
Normal file
225
lib/bds/ui/code_editor/overlay.ex
Normal 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
|
||||
205
lib/bds/ui/code_editor/widget.ex
Normal file
205
lib/bds/ui/code_editor/widget.ex
Normal 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
|
||||
@@ -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},
|
||||
|
||||
107
lib/bds/ui/post_editor/draft.ex
Normal file
107
lib/bds/ui/post_editor/draft.ex
Normal 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
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|
||||
defmodule BDS.UI.PostEditor.ListValues do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.{Metadata, Tags}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
502
lib/bds/ui/settings_form.ex
Normal 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
|
||||
@@ -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
240
lib/bds/ui/tags_panel.ex
Normal 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
|
||||
91
lib/bds/ui/task_grouping.ex
Normal file
91
lib/bds/ui/task_grouping.ex
Normal 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
38
mix.exs
@@ -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
|
||||
|
||||
2
mix.lock
2
mix.lock
@@ -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"},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ""
|
||||
|
||||
1012
priv/gettext/ui.pot
1012
priv/gettext/ui.pot
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
18
priv/static/assets/app.js
vendored
18
priv/static/assets/app.js
vendored
File diff suppressed because one or more lines are too long
23
rel/overlays/cli/bin/bds-cli
Executable file
23
rel/overlays/cli/bin/bds-cli
Executable 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()"
|
||||
@@ -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
175
specs/cli.allium
Normal 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().
|
||||
}
|
||||
@@ -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
59
specs/events.allium
Normal 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()
|
||||
}
|
||||
@@ -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 != ""
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
90
specs/server.allium
Normal 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
211
specs/tui.allium
Normal 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
Reference in New Issue
Block a user