Compare commits
23 Commits
a8ca3b643b
...
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 |
@@ -30,6 +30,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
- you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
|
- you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
|
||||||
- you MUST run build, test, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits and clean dialyzer results
|
- you MUST run build, test, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits and clean dialyzer results
|
||||||
- on a headless Linux machine, you have to run tests with this command (if mix test complains about DISPLAX): xvfb-run mix test
|
- on a headless Linux machine, you have to run tests with this command (if mix test complains about DISPLAX): xvfb-run mix test
|
||||||
|
- CSS precedence: Tailwind utilities and the ui-* kit (utilities.css) are in @layer and ALWAYS lose to the unlayered hand-written files (shell.css, panel.css, ...). Convention: hand-written CSS owns appearance, Tailwind utilities in templates own layout — never put display/flex-direction/size rules in hand-written CSS for elements whose layout the template controls, and define each selector in exactly one file.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
27
API.md
27
API.md
@@ -40,6 +40,7 @@ local meta = bds.meta.get_project_metadata()
|
|||||||
- [app.get_default_project_path](#appget_default_project_path)
|
- [app.get_default_project_path](#appget_default_project_path)
|
||||||
- [app.get_system_language](#appget_system_language)
|
- [app.get_system_language](#appget_system_language)
|
||||||
- [app.get_title_bar_metrics](#appget_title_bar_metrics)
|
- [app.get_title_bar_metrics](#appget_title_bar_metrics)
|
||||||
|
- [app.log](#applog)
|
||||||
- [app.notify_renderer_ready](#appnotify_renderer_ready)
|
- [app.notify_renderer_ready](#appnotify_renderer_ready)
|
||||||
- [app.open_folder](#appopen_folder)
|
- [app.open_folder](#appopen_folder)
|
||||||
- [app.read_project_metadata](#appread_project_metadata)
|
- [app.read_project_metadata](#appread_project_metadata)
|
||||||
@@ -202,6 +203,30 @@ nil -- or
|
|||||||
local result = bds.app.get_title_bar_metrics()
|
local result = bds.app.get_title_bar_metrics()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### app.log
|
||||||
|
|
||||||
|
Append a line to the script output stream. Multiple arguments are joined with spaces. Output appears in the desktop app's Output panel (and on stdout in the CLI); Lua's global `print` is routed the same way.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- text (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `boolean`
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.log("value")
|
||||||
|
```
|
||||||
|
|
||||||
### app.notify_renderer_ready
|
### app.notify_renderer_ready
|
||||||
|
|
||||||
Notify the host application that the renderer is ready.
|
Notify the host application that the renderer is ready.
|
||||||
@@ -3443,7 +3468,7 @@ local result = bds.meta.sync_on_startup()
|
|||||||
|
|
||||||
### meta.update_project_metadata
|
### meta.update_project_metadata
|
||||||
|
|
||||||
Update metadata for the current project.
|
Update metadata for the current project. Keys omitted from updates keep their current values.
|
||||||
|
|
||||||
**Parameters**
|
**Parameters**
|
||||||
|
|
||||||
|
|||||||
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;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar-button:hover,
|
.editor-toolbar-button:hover {
|
||||||
.panel-tab:hover {
|
|
||||||
background: var(--vscode-toolbar-hoverBackground);
|
background: var(--vscode-toolbar-hoverBackground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -377,6 +377,76 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insert-modal-results {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-item:hover {
|
||||||
|
background: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-title {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-meta {
|
||||||
|
color: #9d9d9d;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-status {
|
||||||
|
padding: 16px 20px;
|
||||||
|
color: #9d9d9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-external {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-external .insert-modal-input {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-label {
|
||||||
|
color: #9d9d9d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-submit {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Alert modal (errors) — reuses .confirm-delete-modal with a tone accent. */
|
/* Alert modal (errors) — reuses .confirm-delete-modal with a tone accent. */
|
||||||
.alert-modal-error {
|
.alert-modal-error {
|
||||||
|
|||||||
@@ -8,14 +8,11 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tabs {
|
.panel-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tab {
|
.panel-tab {
|
||||||
@@ -246,6 +243,12 @@
|
|||||||
background-color: var(--vscode-sideBar-background);
|
background-color: var(--vscode-sideBar-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* .panel-entry (shell.css) forces flex-direction: column and, being unlayered,
|
||||||
|
beats the Tailwind row utilities on this button — force the row here. */
|
||||||
|
.task-group-toggle {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
.output-entry {
|
.output-entry {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
|||||||
@@ -402,24 +402,6 @@
|
|||||||
border-bottom: 1px solid var(--vscode-panel-border);
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tab {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--vscode-descriptionForeground);
|
|
||||||
padding: 0 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tab.active {
|
|
||||||
color: var(--vscode-tab-activeForeground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-close {
|
.panel-close {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -778,11 +760,6 @@
|
|||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-meta {
|
.editor-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -794,13 +771,3 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header,
|
|
||||||
.assistant-header,
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "../utils/shortcuts.js";
|
} from "../utils/shortcuts.js";
|
||||||
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
|
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
|
||||||
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
|
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
|
||||||
|
import { copyTextToClipboard } from "../utils/clipboard.js";
|
||||||
|
|
||||||
export const AppShell = {
|
export const AppShell = {
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -179,6 +180,10 @@ export const AppShell = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.handleEvent("clipboard", ({ text }) => {
|
||||||
|
copyTextToClipboard(text || "");
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
||||||
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
|
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
|
||||||
this.el.addEventListener("load", this.handleThumbnailLoad, true);
|
this.el.addEventListener("load", this.handleThumbnailLoad, true);
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ export const webKitTextAreaArrowCommand = (event) => {
|
|||||||
return event.shiftKey ? `${command}Select` : command;
|
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) => {
|
export const bridgeWebKitTextAreaArrowKey = (editor, event) => {
|
||||||
if (!editor || !isMonacoInputArea(event.target)) {
|
if (!editor || !isMonacoInputArea(event.target)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -92,8 +106,12 @@ export const MonacoEditor = {
|
|||||||
|
|
||||||
if (this.editor.getValue() !== value) {
|
if (this.editor.getValue() !== value) {
|
||||||
this.isApplyingRemoteUpdate = true;
|
this.isApplyingRemoteUpdate = true;
|
||||||
this.editor.setValue(value);
|
|
||||||
this.isApplyingRemoteUpdate = false;
|
try {
|
||||||
|
applyRemoteEditorValue(this.editor, value);
|
||||||
|
} finally {
|
||||||
|
this.isApplyingRemoteUpdate = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastKnownValue = value;
|
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);
|
||||||
|
};
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import Config
|
import Config
|
||||||
|
|
||||||
|
# Headless boots (BDS_MODE=server/tui, or desktop mode without a graphical
|
||||||
|
# display — issue #33) must neuter wx before the :desktop dependency
|
||||||
|
# application starts it and crashes the VM. Local tui mode additionally
|
||||||
|
# silences all other terminal writers (console logging goes to a file,
|
||||||
|
# model-download progress bars are disabled) — the TUI owns the terminal
|
||||||
|
# and every stray line scrolls its rendering up one row. Runtime config is
|
||||||
|
# the only hook that runs before dependency applications start.
|
||||||
|
BDS.Server.prepare_boot_env()
|
||||||
|
|
||||||
if config_env() == :prod do
|
if config_env() == :prod do
|
||||||
database_path =
|
database_path =
|
||||||
System.get_env("BDS_DATABASE_PATH") ||
|
System.get_env("BDS_DATABASE_PATH") ||
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ defmodule BDS.Application do
|
|||||||
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher, BDS.Server]
|
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher, BDS.Server]
|
||||||
end
|
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
|
# 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
|
# 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.
|
# this TUI stops the whole VM — the terminal is the app in this mode.
|
||||||
@@ -57,7 +62,7 @@ defmodule BDS.Application do
|
|||||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||||
BDS.Scripting.JobSupervisor,
|
BDS.Scripting.JobSupervisor,
|
||||||
BDS.Embeddings.Index
|
BDS.Embeddings.Index
|
||||||
] ++ embedding_children() ++ mode_children(BDS.Server.mode(), current_env())
|
] ++ embedding_children() ++ mode_children(boot_mode(), current_env())
|
||||||
|
|
||||||
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||||
Supervisor.start_link(children, opts)
|
Supervisor.start_link(children, opts)
|
||||||
@@ -77,6 +82,20 @@ defmodule BDS.Application do
|
|||||||
Application.get_env(:bds, :current_env_override) || @compiled_env
|
Application.get_env(:bds, :current_env_override) || @compiled_env
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Desktop mode without a graphical display boots the TUI instead
|
||||||
|
# (issue #33); the log line explains the surprise mode switch.
|
||||||
|
defp boot_mode do
|
||||||
|
resolved = BDS.Server.mode()
|
||||||
|
effective = BDS.Server.effective_mode(resolved)
|
||||||
|
|
||||||
|
if effective != resolved do
|
||||||
|
require Logger
|
||||||
|
Logger.info("No graphical display available, starting the terminal UI instead")
|
||||||
|
end
|
||||||
|
|
||||||
|
effective
|
||||||
|
end
|
||||||
|
|
||||||
defp desktop_window_children do
|
defp desktop_window_children do
|
||||||
if desktop_automation?() do
|
if desktop_automation?() do
|
||||||
[]
|
[]
|
||||||
|
|||||||
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
|
||||||
@@ -120,7 +120,7 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
"rebuild_embedding_index",
|
"rebuild_embedding_index",
|
||||||
"Rebuild Embedding Index",
|
"Rebuild Embedding Index",
|
||||||
"Embeddings",
|
"Embeddings",
|
||||||
fn report -> rebuild_embedding_index_work(project, report) end
|
fn report -> Maintenance.rebuild_embedding_index(project.id, on_progress: report) end
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
defp dispatch("rebuild_database", project, _params) do
|
defp dispatch("rebuild_database", project, _params) do
|
||||||
group_id = task_group_id("rebuild_database")
|
group_id = task_group_id("rebuild_database")
|
||||||
attrs = %{group_id: group_id, group_name: "Maintenance"}
|
attrs = %{group_id: group_id, group_name: "Maintenance"}
|
||||||
[first_step | remaining_steps] = rebuild_database_steps(project)
|
[first_step | remaining_steps] = Maintenance.full_rebuild_steps(project.id)
|
||||||
|
|
||||||
{:ok, posts_task} =
|
{:ok, posts_task} =
|
||||||
Tasks.submit_task(first_step.name, first_step.work, attrs)
|
Tasks.submit_task(first_step.name, first_step.work, attrs)
|
||||||
@@ -692,102 +692,6 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
|> length() > 1
|
|> length() > 1
|
||||||
end
|
end
|
||||||
|
|
||||||
defp rebuild_database_steps(project) do
|
|
||||||
[
|
|
||||||
%{
|
|
||||||
name: "Rebuild Posts From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, posts} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "post",
|
|
||||||
on_progress: report,
|
|
||||||
rebuild_embeddings: false
|
|
||||||
)
|
|
||||||
|
|
||||||
report.(1.0, "Post rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{posts: length(posts)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Media From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, media} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
|
|
||||||
|
|
||||||
report.(1.0, "Media rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{media: length(media)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Scripts From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, scripts} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
|
|
||||||
|
|
||||||
report.(1.0, "Script rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{scripts: length(scripts)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Templates From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, templates} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
|
|
||||||
|
|
||||||
report.(1.0, "Template rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{templates: length(templates)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Post Links",
|
|
||||||
work: fn report ->
|
|
||||||
:ok = Posts.rebuild_post_links(project.id, on_progress: report)
|
|
||||||
report.(1.0, "Post links rebuilt")
|
|
||||||
%{project_id: project.id}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Regenerate Missing Thumbnails",
|
|
||||||
work: fn report ->
|
|
||||||
result = BDS.Media.regenerate_missing_thumbnails(project.id, on_progress: report)
|
|
||||||
report.(1.0, "Missing thumbnails regenerated")
|
|
||||||
Map.put(result, :project_id, project.id)
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Embedding Index",
|
|
||||||
work: fn report -> rebuild_embedding_index_work(project, report) end
|
|
||||||
}
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
defp rebuild_embedding_index_work(project, report) do
|
|
||||||
case Embeddings.rebuild_project(project.id, on_progress: report) do
|
|
||||||
{:ok, rebuilt_post_ids} ->
|
|
||||||
report.(1.0, "Embedding index rebuilt")
|
|
||||||
|
|
||||||
%{
|
|
||||||
project_id: project.id,
|
|
||||||
rebuilt_post_ids: rebuilt_post_ids,
|
|
||||||
rebuilt_count: length(rebuilt_post_ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
{:error, embedding_error_message(reason)}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp embedding_error_message(reason) do
|
|
||||||
detail =
|
|
||||||
case reason do
|
|
||||||
message when is_binary(message) -> message
|
|
||||||
{:embedding_backend_unavailable, _inner} -> "the embedding service did not start"
|
|
||||||
other -> inspect(other)
|
|
||||||
end
|
|
||||||
|
|
||||||
"Could not build the embedding index: #{detail}. The model is downloaded on first use, " <>
|
|
||||||
"so check your internet connection — or turn off semantic similarity in Settings."
|
|
||||||
end
|
|
||||||
|
|
||||||
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
|
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
|
||||||
|
|
||||||
defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do
|
defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:shell_overlay, nil)
|
|> assign(:shell_overlay, nil)
|
||||||
|> assign(:git_run, nil)
|
|> assign(:git_run, nil)
|
||||||
|> assign(:output_entries, [])
|
|> assign(:output_entries, [])
|
||||||
|
|> assign(:collapsed_task_groups, MapSet.new())
|
||||||
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
||||||
|> assign(:panel_git_entries, [])
|
|> assign(:panel_git_entries, [])
|
||||||
|> assign(:auto_save_timers, %{})
|
|> assign(:auto_save_timers, %{})
|
||||||
@@ -226,6 +227,38 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply, refresh_layout(socket, workbench)}
|
{:noreply, refresh_layout(socket, workbench)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Copy-all for the Output panel: entries are stored newest-first, so flip
|
||||||
|
# them back to chronological order before joining (old-app behavior).
|
||||||
|
def handle_event("copy_output", _params, socket) do
|
||||||
|
text =
|
||||||
|
socket.assigns.output_entries
|
||||||
|
|> Enum.reverse()
|
||||||
|
|> Enum.map_join("\n\n", & &1.message)
|
||||||
|
|
||||||
|
{:noreply, push_event(socket, "clipboard", %{text: text})}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("toggle_task_group", %{"group" => group_id}, socket) do
|
||||||
|
collapsed = socket.assigns[:collapsed_task_groups] || MapSet.new()
|
||||||
|
|
||||||
|
collapsed =
|
||||||
|
if MapSet.member?(collapsed, group_id),
|
||||||
|
do: MapSet.delete(collapsed, group_id),
|
||||||
|
else: MapSet.put(collapsed, group_id)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :collapsed_task_groups, collapsed)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("cancel_task", %{"id" => task_id}, socket) do
|
||||||
|
_ = BDS.Tasks.cancel_task(task_id)
|
||||||
|
|
||||||
|
task_status =
|
||||||
|
BDS.Tasks.status_snapshot()
|
||||||
|
|> BDS.Desktop.ShellLive.TaskLocalization.localize_task_status(socket.assigns.page_language)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :task_status, task_status)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
|
def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
|
||||||
{:noreply, open_sidebar_item(socket, params, :preview)}
|
{:noreply, open_sidebar_item(socket, params, :preview)}
|
||||||
end
|
end
|
||||||
@@ -860,33 +893,66 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp import_blogmark(socket, project_id, url, title) do
|
defp import_blogmark(socket, project_id, url, title) do
|
||||||
case Blogmark.receive_deep_link(project_id, url) do
|
# Transform scripts run in the sandbox process; collect anything they
|
||||||
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
|
# print/log so it lands in the Output panel like in the old app.
|
||||||
socket
|
{:ok, output_sink} = Agent.start_link(fn -> [] end)
|
||||||
|> reload_shell(socket.assigns.workbench)
|
|
||||||
|> open_sidebar_item(
|
|
||||||
%{
|
|
||||||
"route" => "post",
|
|
||||||
"id" => post.id,
|
|
||||||
"title" => post.title,
|
|
||||||
"subtitle" => post.slug
|
|
||||||
},
|
|
||||||
:pin
|
|
||||||
)
|
|
||||||
|> append_blogmark_toasts(title, toasts)
|
|
||||||
|> append_blogmark_errors(title, errors)
|
|
||||||
|
|
||||||
{:error, reason} ->
|
sink = fn text -> Agent.update(output_sink, fn lines -> [text | lines] end) end
|
||||||
append_output_entry(socket, title, inspect(reason), url, "error")
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp append_blogmark_script_output(socket, title, lines) do
|
||||||
|
Enum.reduce(lines, socket, fn line, acc ->
|
||||||
|
append_output_entry(acc, title, line, nil, "info")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp append_blogmark_toasts(socket, title, toasts) do
|
defp append_blogmark_toasts(socket, title, toasts) do
|
||||||
Enum.reduce(toasts, socket, fn message, acc ->
|
Enum.reduce(toasts, socket, fn message, acc ->
|
||||||
append_output_entry(acc, title, message, nil, "info")
|
append_output_entry(acc, title, message, nil, "info")
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Old-app parity: transform errors or streamed script output open the
|
||||||
|
# bottom panel on the Output tab so the user actually sees them.
|
||||||
|
defp open_output_panel_when(socket, true) do
|
||||||
|
workbench =
|
||||||
|
socket.assigns.workbench
|
||||||
|
|> Workbench.set_panel_visible(true)
|
||||||
|
|> Workbench.set_panel_tab(:output)
|
||||||
|
|
||||||
|
refresh_layout(socket, workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_output_panel_when(socket, false), do: socket
|
||||||
|
|
||||||
defp append_blogmark_errors(socket, title, errors) do
|
defp append_blogmark_errors(socket, title, errors) do
|
||||||
Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc ->
|
Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc ->
|
||||||
append_output_entry(acc, title, inspect(reason), slug, "error")
|
append_output_entry(acc, title, inspect(reason), slug, "error")
|
||||||
|
|||||||
@@ -15,14 +15,7 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
|
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
|
||||||
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
|
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
|
||||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
main_language = metadata.main_language || language
|
translate_targets = translate_targets(metadata, language)
|
||||||
blog_languages = metadata.blog_languages || []
|
|
||||||
|
|
||||||
translate_targets =
|
|
||||||
[main_language | blog_languages]
|
|
||||||
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
|
||||||
|> Enum.uniq()
|
|
||||||
|
|
||||||
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
|
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
|
||||||
|
|
||||||
tasks =
|
tasks =
|
||||||
@@ -48,6 +41,36 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
send(parent, {:add_images_complete, length(paths)})
|
send(parent, {:add_images_complete, length(paths)})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The translation targets for AI-generated media texts: the project main
|
||||||
|
language plus all blog languages, minus the source `language`.
|
||||||
|
"""
|
||||||
|
def translate_targets(metadata, language) do
|
||||||
|
main_language = metadata.main_language || language
|
||||||
|
blog_languages = metadata.blog_languages || []
|
||||||
|
|
||||||
|
[main_language | blog_languages]
|
||||||
|
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Imports a single file and runs the same best-effort AI enrichment
|
||||||
|
(title/alt/caption plus translations) as the gallery pipeline, without
|
||||||
|
linking the media to a post. Used by the CLI `media` command (issue #25).
|
||||||
|
|
||||||
|
Returns `{:ok, media, display_title}` or `{:error, reason}`.
|
||||||
|
"""
|
||||||
|
def import_and_enrich(path, project_id, language, translate_targets) do
|
||||||
|
case Media.import_media(%{project_id: project_id, source_path: path}) do
|
||||||
|
{:ok, media} ->
|
||||||
|
{:ok, media, enrich_media(media, language, translate_targets)}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp drain_tasks(
|
defp drain_tasks(
|
||||||
[],
|
[],
|
||||||
tasks,
|
tasks,
|
||||||
|
|||||||
@@ -457,7 +457,7 @@
|
|||||||
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
|
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
|
||||||
<%= for tab <- @panel_tabs do %>
|
<%= for tab <- @panel_tabs do %>
|
||||||
<button
|
<button
|
||||||
class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex h-9 items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]}
|
class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="select_panel_tab"
|
phx-click="select_panel_tab"
|
||||||
phx-value-tab={tab}
|
phx-value-tab={tab}
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ defmodule BDS.Desktop.ShellLive.Notify do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sends an output entry to a specific LiveView process. Needed when the
|
||||||
|
caller runs in a different process than the shell — e.g. script output
|
||||||
|
callbacks invoked inside the sandboxed script process.
|
||||||
|
"""
|
||||||
|
@spec output_to(pid(), String.t(), String.t(), String.t() | nil, String.t()) :: :ok
|
||||||
|
def output_to(pid, title, message, detail, level) when is_pid(pid) do
|
||||||
|
send(pid, {:editor_output, title, message, detail, level})
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
@spec alert(String.t(), String.t()) :: :ok
|
@spec alert(String.t(), String.t()) :: :ok
|
||||||
def alert(title, message) do
|
def alert(title, message) do
|
||||||
send(self(), {:shell_alert, title, message})
|
send(self(), {:shell_alert, title, message})
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
|||||||
ShellOverlayComponents.markdown_link(result.title, result.canonical_url)}
|
ShellOverlayComponents.markdown_link(result.title, result.canonical_url)}
|
||||||
)
|
)
|
||||||
|
|
||||||
socket
|
assign(socket, :shell_overlay, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
{%{kind: :insert_media}, %{type: :post, id: post_id}} ->
|
{%{kind: :insert_media}, %{type: :post, id: post_id}} ->
|
||||||
@@ -172,7 +172,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
|||||||
end
|
end
|
||||||
|
|
||||||
Notify.parent({:post_editor_insert_content, post_id, syntax})
|
Notify.parent({:post_editor_insert_content, post_id, syntax})
|
||||||
socket
|
assign(socket, :shell_overlay, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
@@ -197,10 +197,11 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
|||||||
|
|
||||||
if details do
|
if details do
|
||||||
Notify.parent({:post_editor_insert_content, post_id, details})
|
Notify.parent({:post_editor_insert_content, post_id, details})
|
||||||
|
assign(socket, :shell_overlay, nil)
|
||||||
|
else
|
||||||
|
socket
|
||||||
end
|
end
|
||||||
|
|
||||||
socket
|
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
socket
|
socket
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
|||||||
alias BDS.PostLinks
|
alias BDS.PostLinks
|
||||||
alias BDS.Posts
|
alias BDS.Posts
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.UI.TaskGrouping
|
||||||
use Gettext, backend: BDS.Gettext
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
@doc "Render the active panel tab body."
|
@doc "Render the active panel tab body."
|
||||||
@@ -48,34 +49,122 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp render_task_entries(assigns) do
|
defp render_task_entries(assigns) do
|
||||||
|
entries = TaskGrouping.build_task_entries(Map.get(assigns.task_status, :tasks, []))
|
||||||
|
collapsed = Map.get(assigns, :collapsed_task_groups) || MapSet.new()
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:task_entries, entries)
|
||||||
|
|> assign(:collapsed_task_groups, collapsed)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %>
|
<%= if Enum.empty?(@task_entries) do %>
|
||||||
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
|
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
|
||||||
<strong><%= dgettext("ui", "Tasks") %></strong>
|
<strong><%= dgettext("ui", "Tasks") %></strong>
|
||||||
<span><%= dgettext("ui", "No background tasks running") %></span>
|
<span><%= dgettext("ui", "No background tasks running") %></span>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="task-list flex flex-col gap-2">
|
<div class="task-list flex flex-col gap-2">
|
||||||
<%= for task <- Map.get(@task_status, :tasks, []) do %>
|
<%= for entry <- @task_entries do %>
|
||||||
<div class="panel-entry ui-panel-entry task-entry flex flex-col gap-2">
|
<%= case entry do %>
|
||||||
<div class="task-entry-header flex items-center justify-between gap-2">
|
<% {:single, task} -> %>
|
||||||
<strong><%= task.name %></strong>
|
{render_task_row(assigns, task, false)}
|
||||||
<span class={"task-status task-status-#{task.status}"}><%= Map.get(task, :status_label, task.status |> to_string() |> String.capitalize()) %></span>
|
<% {:group, group_id, group_name, tasks} -> %>
|
||||||
</div>
|
{render_task_group(assigns, group_id, group_name, tasks)}
|
||||||
<span><%= task.message || task.group_name || "" %></span>
|
<% end %>
|
||||||
<%= 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>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp render_task_group(assigns, group_id, group_name, tasks) do
|
||||||
|
summary = TaskGrouping.summarize_task_group(tasks)
|
||||||
|
expanded = not MapSet.member?(assigns.collapsed_task_groups, group_id)
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:group_id, group_id)
|
||||||
|
|> assign(:group_name, group_name)
|
||||||
|
|> assign(:group_tasks, tasks)
|
||||||
|
|> assign(:group_expanded, expanded)
|
||||||
|
|> assign(:group_meta, task_group_meta(summary))
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="task-group flex flex-col">
|
||||||
|
<button
|
||||||
|
class="panel-entry ui-panel-entry task-entry task-group-toggle flex w-full items-center gap-2 text-left"
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_task_group"
|
||||||
|
phx-value-group={@group_id}
|
||||||
|
aria-expanded={to_string(@group_expanded)}
|
||||||
|
>
|
||||||
|
<span class="task-group-chevron">{if @group_expanded, do: "▾", else: "▸"}</span>
|
||||||
|
<strong class="task-group-title"><%= @group_name %> (<%= length(@group_tasks) %>)</strong>
|
||||||
|
<span class="task-group-meta text-muted text-xs"><%= @group_meta %></span>
|
||||||
|
</button>
|
||||||
|
<%= if @group_expanded do %>
|
||||||
|
<%= for task <- @group_tasks do %>
|
||||||
|
{render_task_row(assigns, task, true)}
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_task_row(assigns, task, is_child) do
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:task, task)
|
||||||
|
|> assign(:task_child?, is_child)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class={["panel-entry", "ui-panel-entry", "task-entry", "flex flex-col gap-2", if(@task_child?, do: "ml-4")]}>
|
||||||
|
<div class="task-entry-header flex items-center justify-between gap-2">
|
||||||
|
<strong><%= @task.name %></strong>
|
||||||
|
<span class={"task-status task-status-#{@task.status}"}><%= Map.get(@task, :status_label, @task.status |> to_string() |> String.capitalize()) %></span>
|
||||||
|
</div>
|
||||||
|
<span><%= @task.message || @task.group_name || "" %></span>
|
||||||
|
<%= if is_number(@task.progress) do %>
|
||||||
|
<div class="task-progress-row">
|
||||||
|
<progress max="1" value={@task.progress}></progress>
|
||||||
|
<span><%= Map.get(@task, :progress_label, progress_percent(@task.progress)) %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<%= if @task.status == :running do %>
|
||||||
|
<div class="task-entry-actions flex justify-end">
|
||||||
|
<button
|
||||||
|
class="ui-button ui-button-secondary inline-flex items-center px-2 py-1 text-xs"
|
||||||
|
data-testid="cancel-task-button"
|
||||||
|
type="button"
|
||||||
|
phx-click="cancel_task"
|
||||||
|
phx-value-id={@task.id}
|
||||||
|
>
|
||||||
|
<%= dgettext("ui", "Cancel") %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# "NN% · X Running · Y Pending" — the breakdown only lists non-zero counts,
|
||||||
|
# like the old app's summarizeTaskGroup rendering.
|
||||||
|
defp task_group_meta(summary) do
|
||||||
|
parts =
|
||||||
|
[]
|
||||||
|
|> maybe_append_breakdown(summary.running, dgettext("ui", "Running"))
|
||||||
|
|> maybe_append_breakdown(summary.pending, dgettext("ui", "Pending"))
|
||||||
|
|
||||||
|
suffix = if parts == [], do: "", else: " · " <> Enum.join(parts, " · ")
|
||||||
|
progress_percent(summary.progress) <> suffix
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_append_breakdown(parts, count, _label) when count <= 0, do: parts
|
||||||
|
|
||||||
|
defp maybe_append_breakdown(parts, count, label),
|
||||||
|
do: parts ++ ["#{count} #{label}"]
|
||||||
|
|
||||||
defp render_output_entries(assigns) do
|
defp render_output_entries(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= if Enum.empty?(@output_entries) do %>
|
<%= if Enum.empty?(@output_entries) do %>
|
||||||
@@ -84,6 +173,16 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
|||||||
<span><%= dgettext("ui", "No shell output yet") %></span>
|
<span><%= dgettext("ui", "No shell output yet") %></span>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
<div class="output-toolbar flex shrink-0 justify-end">
|
||||||
|
<button
|
||||||
|
class="ui-button ui-button-secondary inline-flex items-center px-2 py-1 text-xs"
|
||||||
|
data-testid="copy-output-button"
|
||||||
|
type="button"
|
||||||
|
phx-click="copy_output"
|
||||||
|
>
|
||||||
|
<%= dgettext("ui", "Copy") %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="output-list flex flex-col gap-2">
|
<div class="output-list flex flex-col gap-2">
|
||||||
<%= for entry <- @output_entries do %>
|
<%= for entry <- @output_entries do %>
|
||||||
<div class={[
|
<div class={[
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
id: socket.assigns.post_id,
|
id: socket.assigns.post_id,
|
||||||
content: content
|
content: content
|
||||||
})
|
})
|
||||||
|> assign(:shell_overlay, nil)
|
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
@@ -316,18 +315,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
{:noreply, do_remove_list_value(socket, :categories, category)}
|
{:noreply, do_remove_list_value(socket, :categories, category)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("insert_content", %{"content" => content}, socket) do
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{
|
|
||||||
id: socket.assigns.post_id,
|
|
||||||
content: content
|
|
||||||
})
|
|
||||||
|> assign(:shell_overlay, nil)
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("close_quick_actions", _params, socket) do
|
def handle_event("close_quick_actions", _params, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -892,7 +879,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
if map_size(attrs) == 0 do
|
if map_size(attrs) == 0 do
|
||||||
assign(socket, :shell_overlay, nil)
|
socket
|
||||||
else
|
else
|
||||||
case Posts.update_post(post_id, attrs) do
|
case Posts.update_post(post_id, attrs) do
|
||||||
{:ok, updated_post} ->
|
{:ok, updated_post} ->
|
||||||
@@ -910,7 +897,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
)
|
)
|
||||||
|> assign(:save_state, :dirty)
|
|> assign(:save_state, :dirty)
|
||||||
|> assign(:dirty?, true)
|
|> assign(:dirty?, true)
|
||||||
|> assign(:shell_overlay, nil)
|
|
||||||
|> build_data()
|
|> build_data()
|
||||||
|
|
||||||
Notify.dirty(:post, post_id, true)
|
Notify.dirty(:post, post_id, true)
|
||||||
|
|||||||
@@ -206,11 +206,17 @@ defmodule BDS.Desktop.ShellLive.ScriptEditor do
|
|||||||
%Script{} = script ->
|
%Script{} = script ->
|
||||||
draft = current_draft(socket.assigns, script)
|
draft = current_draft(socket.assigns, script)
|
||||||
|
|
||||||
|
# The sandbox runs in its own process, so streamed script output is
|
||||||
|
# routed back to this LiveView explicitly.
|
||||||
|
live_view = self()
|
||||||
|
title = dgettext("ui", "Scripts")
|
||||||
|
|
||||||
case Scripting.execute_project_script(
|
case Scripting.execute_project_script(
|
||||||
script.project_id,
|
script.project_id,
|
||||||
draft["content"] || "",
|
draft["content"] || "",
|
||||||
draft["entrypoint"] || "main",
|
draft["entrypoint"] || "main",
|
||||||
[]
|
[],
|
||||||
|
on_output: fn text -> Notify.output_to(live_view, title, text, nil, "info") end
|
||||||
) do
|
) do
|
||||||
{:ok, result} ->
|
{:ok, result} ->
|
||||||
notify_output(socket, dgettext("ui", "Scripts"), inspect(result))
|
notify_output(socket, dgettext("ui", "Scripts"), inspect(result))
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("install_cli_tool", _params, socket) do
|
||||||
|
case BDS.UI.SettingsForm.run_action("install_cli") do
|
||||||
|
{:ok, message} -> LiveToast.send_toast(:info, message)
|
||||||
|
{:error, message} -> LiveToast.send_toast(:error, message)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("change_settings_search", %{"query" => query}, socket) do
|
def handle_event("change_settings_search", %{"query" => query}, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -302,7 +311,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
|||||||
query,
|
query,
|
||||||
~w(mcp claude copilot gemini opencode mistral codex agent server)
|
~w(mcp claude copilot gemini opencode mistral codex agent server)
|
||||||
),
|
),
|
||||||
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem)),
|
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem cli)),
|
||||||
supported_languages: @supported_languages,
|
supported_languages: @supported_languages,
|
||||||
protected_categories: ManagedCategories.protected_categories()
|
protected_categories: ManagedCategories.protected_categories()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,24 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
|||||||
defp do_save_ai(socket, reload, append_output) do
|
defp do_save_ai(socket, reload, append_output) do
|
||||||
attrs = ai_attrs(socket.assigns)
|
attrs = ai_attrs(socket.assigns)
|
||||||
|
|
||||||
|
case save_attrs(attrs) do
|
||||||
|
:ok ->
|
||||||
|
LiveToast.send_toast(:info, dgettext("ui", "AI settings saved."))
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:settings_editor_ai_draft, %{})
|
||||||
|
|> assign(:offline_mode, attrs.offline_mode)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
surface_ai_error(socket, reload, append_output, reason)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The full save pipeline as a pure function so the TUI settings form
|
||||||
|
# (BDS.UI.SettingsForm) drives the exact same writes as the GUI editor.
|
||||||
|
@spec save_attrs(map()) :: :ok | {:error, term()}
|
||||||
|
def save_attrs(attrs) do
|
||||||
with :ok <-
|
with :ok <-
|
||||||
put_endpoint_preferences(
|
put_endpoint_preferences(
|
||||||
:online,
|
:online,
|
||||||
@@ -156,15 +174,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
|||||||
attrs.offline_chat_images
|
attrs.offline_chat_images
|
||||||
),
|
),
|
||||||
:ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do
|
:ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do
|
||||||
LiveToast.send_toast(:info, dgettext("ui", "AI settings saved."))
|
:ok
|
||||||
|
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
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="rebuild_embedding_index"><%= dgettext("ui", "Rebuild Embedding Index") %></button>
|
||||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label class="setting-label"><%= dgettext("ui", "Command Line Tool") %></label>
|
||||||
|
<p class="setting-description"><%= dgettext("ui", "Install the bds-cli tool to ~/.local/bin; it uses the same settings and cache database as the app") %></p>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="install_cli_tool" phx-target={@myself}><%= dgettext("ui", "Install CLI Tool") %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
|||||||
|
|
||||||
alias BDS.{Repo, Tags}
|
alias BDS.{Repo, Tags}
|
||||||
alias BDS.Desktop.ShellLive.Notify
|
alias BDS.Desktop.ShellLive.Notify
|
||||||
alias BDS.Posts.Post
|
|
||||||
alias BDS.Tags.Tag
|
alias BDS.Tags.Tag
|
||||||
alias BDS.Templates.Template
|
alias BDS.Templates.Template
|
||||||
use Gettext, backend: BDS.Gettext
|
use Gettext, backend: BDS.Gettext
|
||||||
@@ -16,12 +15,8 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
|||||||
|
|
||||||
@tags_sections ~w(cloud manage merge)
|
@tags_sections ~w(cloud manage merge)
|
||||||
|
|
||||||
@colour_presets ~w(
|
# Shared with the TUI tags panel (issue #34) via BDS.UI.TagsPanel.
|
||||||
#ef4444 #f97316 #f59e0b #eab308 #84cc16
|
@colour_presets BDS.UI.TagsPanel.colour_presets()
|
||||||
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
|
|
||||||
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
|
|
||||||
#ec4899 #64748b
|
|
||||||
)
|
|
||||||
|
|
||||||
@spec colour_presets() :: [String.t()]
|
@spec colour_presets() :: [String.t()]
|
||||||
def colour_presets, do: @colour_presets
|
def colour_presets, do: @colour_presets
|
||||||
@@ -439,12 +434,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
|||||||
|
|
||||||
defp current_tab_meta(_assigns), do: %{}
|
defp current_tab_meta(_assigns), do: %{}
|
||||||
|
|
||||||
defp tag_counts(project_id) do
|
defp tag_counts(project_id), do: BDS.UI.TagsPanel.tag_counts(project_id)
|
||||||
Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.tags)
|
|
||||||
|> List.flatten()
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
|> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp blank_to_nil(nil), do: nil
|
defp blank_to_nil(nil), do: nil
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ defmodule BDS.Embeddings.Backends.Neural do
|
|||||||
* Everywhere else — and as a fallback when EMLX is unavailable or explicitly
|
* Everywhere else — and as a fallback when EMLX is unavailable or explicitly
|
||||||
disabled — it runs on optimised native CPU via XLA (`compiler: EXLA`).
|
disabled — it runs on optimised native CPU via XLA (`compiler: EXLA`).
|
||||||
|
|
||||||
|
Packaged releases contain only the backend that can run on their platform
|
||||||
|
(`runtime:` conditional deps in mix.exs): macOS releases ship EMLX and no
|
||||||
|
EXLA, Linux/Windows ship EXLA and no EMLX (MLX is Apple-only). The selected
|
||||||
|
backend is therefore started on demand here, never assumed at boot.
|
||||||
|
|
||||||
The accelerator can be pinned with `config :bds, :embeddings, accelerator:`
|
The accelerator can be pinned with `config :bds, :embeddings, accelerator:`
|
||||||
to `:auto` (default), `:emlx`, or `:exla`.
|
to `:auto` (default), `:emlx`, or `:exla`.
|
||||||
"""
|
"""
|
||||||
@@ -171,7 +176,20 @@ defmodule BDS.Embeddings.Backends.Neural do
|
|||||||
@doc false
|
@doc false
|
||||||
@spec current_accelerator() :: :emlx | :exla
|
@spec current_accelerator() :: :emlx | :exla
|
||||||
def current_accelerator do
|
def current_accelerator do
|
||||||
select_accelerator(configured_accelerator(), emlx_available?(), apple_silicon?())
|
case select_accelerator(
|
||||||
|
configured_accelerator(),
|
||||||
|
emlx_available?(),
|
||||||
|
exla_available?(),
|
||||||
|
apple_silicon?()
|
||||||
|
) do
|
||||||
|
:none ->
|
||||||
|
raise RuntimeError,
|
||||||
|
"no embeddings accelerator available: neither the EMLX (Apple GPU) nor the " <>
|
||||||
|
"EXLA (CPU) application could be started on this machine"
|
||||||
|
|
||||||
|
accelerator ->
|
||||||
|
accelerator
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -179,22 +197,43 @@ defmodule BDS.Embeddings.Backends.Neural do
|
|||||||
|
|
||||||
Prefer the Apple GPU (EMLX) under `:auto` only when it is both available and
|
Prefer the Apple GPU (EMLX) under `:auto` only when it is both available and
|
||||||
running on Apple Silicon; honour an explicit `:emlx`/`:exla` request, but
|
running on Apple Silicon; honour an explicit `:emlx`/`:exla` request, but
|
||||||
degrade a forced `:emlx` to EXLA when EMLX is not loaded so a misconfigured
|
degrade to the other backend when the requested one is not available so a
|
||||||
host still gets working CPU inference instead of crashing.
|
misconfigured host still gets working inference instead of crashing. Only
|
||||||
|
one backend ships per platform (macOS releases exclude EXLA, Linux/Windows
|
||||||
|
exclude EMLX), so the unavailable side is the norm in production, not an
|
||||||
|
edge case.
|
||||||
"""
|
"""
|
||||||
@spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean()) :: :emlx | :exla
|
@spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean(), boolean()) ::
|
||||||
def select_accelerator(:exla, _emlx_available?, _apple_silicon?), do: :exla
|
:emlx | :exla | :none
|
||||||
def select_accelerator(:emlx, true, _apple_silicon?), do: :emlx
|
def select_accelerator(_configured, false, false, _apple_silicon?), do: :none
|
||||||
def select_accelerator(:emlx, false, _apple_silicon?), do: :exla
|
def select_accelerator(:exla, _emlx?, true, _apple_silicon?), do: :exla
|
||||||
def select_accelerator(:auto, true, true), do: :emlx
|
def select_accelerator(:exla, true, false, _apple_silicon?), do: :emlx
|
||||||
def select_accelerator(:auto, _emlx_available?, _apple_silicon?), do: :exla
|
def select_accelerator(:emlx, true, _exla?, _apple_silicon?), do: :emlx
|
||||||
|
def select_accelerator(:emlx, false, true, _apple_silicon?), do: :exla
|
||||||
|
def select_accelerator(:auto, true, _exla?, true), do: :emlx
|
||||||
|
def select_accelerator(:auto, _emlx?, true, _apple_silicon?), do: :exla
|
||||||
|
def select_accelerator(:auto, true, false, _apple_silicon?), do: :emlx
|
||||||
|
|
||||||
defp configured_accelerator do
|
defp configured_accelerator do
|
||||||
config() |> Keyword.get(:accelerator, @default_accelerator)
|
config() |> Keyword.get(:accelerator, @default_accelerator)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The backend apps are runtime: false deps on the platforms that can't run
|
||||||
|
# them, so releases exclude one of them entirely and neither is auto-started
|
||||||
|
# in dev/test. Availability therefore means "the app actually starts here":
|
||||||
|
# ensure_all_started errors when the app is absent (or its NIF can't load).
|
||||||
|
# Starting the app is also exactly what the chosen backend needs before
|
||||||
|
# building the serving.
|
||||||
defp emlx_available? do
|
defp emlx_available? do
|
||||||
Code.ensure_loaded?(EMLX) and Code.ensure_loaded?(EMLX.Backend)
|
started?(:emlx) and Code.ensure_loaded?(EMLX.Backend)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp exla_available? do
|
||||||
|
started?(:exla)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp started?(app) do
|
||||||
|
match?({:ok, _apps}, Application.ensure_all_started(app))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp apple_silicon? do
|
defp apple_silicon? do
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ defmodule BDS.Generation do
|
|||||||
|
|
||||||
build_core_outputs(
|
build_core_outputs(
|
||||||
plan,
|
plan,
|
||||||
data.published_list_posts,
|
render_data.main_list_posts,
|
||||||
render_data.localized_list_posts_by_language,
|
render_data.localized_list_posts_by_language,
|
||||||
on_output
|
on_output
|
||||||
) ++
|
) ++
|
||||||
@@ -274,7 +274,7 @@ defmodule BDS.Generation do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp full_localized_archive_outputs(plan, render_data, builder) do
|
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 =
|
language_outputs =
|
||||||
Enum.flat_map(additional_languages(plan), fn language ->
|
Enum.flat_map(additional_languages(plan), fn language ->
|
||||||
@@ -286,11 +286,8 @@ defmodule BDS.Generation do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp core_sitemap_outputs(plan, data) do
|
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} =
|
{_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}]
|
[{"sitemap.xml", sitemap_to_write}]
|
||||||
end
|
end
|
||||||
@@ -321,17 +318,13 @@ defmodule BDS.Generation do
|
|||||||
|
|
||||||
{:ok, generated_files_list} = list_generated_files(project_id)
|
{:ok, generated_files_list} = list_generated_files(project_id)
|
||||||
generated_file_updated_at = generated_file_updated_at_map(generated_files_list)
|
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,
|
{sitemap_content, sitemap_to_write, additional_expected_paths,
|
||||||
additional_post_timestamp_checks} =
|
additional_post_timestamp_checks} =
|
||||||
build_validation_sitemap_artifacts(
|
build_validation_sitemap_artifacts(
|
||||||
plan,
|
plan,
|
||||||
data,
|
data,
|
||||||
published_route_posts,
|
data.published_posts,
|
||||||
generated_file_updated_at,
|
generated_file_updated_at,
|
||||||
on_progress
|
on_progress
|
||||||
)
|
)
|
||||||
@@ -350,7 +343,7 @@ defmodule BDS.Generation do
|
|||||||
post_timestamp_checks:
|
post_timestamp_checks:
|
||||||
build_post_timestamp_checks(
|
build_post_timestamp_checks(
|
||||||
data.project_data_dir,
|
data.project_data_dir,
|
||||||
published_route_posts,
|
data.published_posts,
|
||||||
generated_file_updated_at
|
generated_file_updated_at
|
||||||
) ++ additional_post_timestamp_checks,
|
) ++ additional_post_timestamp_checks,
|
||||||
additional_expected_paths: additional_expected_paths
|
additional_expected_paths: additional_expected_paths
|
||||||
@@ -647,53 +640,15 @@ defmodule BDS.Generation do
|
|||||||
|
|
||||||
defp build_outputs(plan, on_output \\ nil) do
|
defp build_outputs(plan, on_output \\ nil) do
|
||||||
plan = with_render_context(plan)
|
plan = with_render_context(plan)
|
||||||
data = generation_data(plan)
|
render_data = validation_render_data(plan)
|
||||||
published_translations = flattened_generation_translations(data.translations_by_post)
|
data = render_data.data
|
||||||
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()
|
|
||||||
|
|
||||||
core_outputs =
|
core_outputs =
|
||||||
if :core in plan.sections do
|
if :core in plan.sections do
|
||||||
build_core_outputs(
|
build_core_outputs(
|
||||||
plan,
|
plan,
|
||||||
data.published_list_posts,
|
render_data.main_list_posts,
|
||||||
localized_list_posts_by_language,
|
render_data.localized_list_posts_by_language,
|
||||||
on_output
|
on_output
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -706,8 +661,8 @@ defmodule BDS.Generation do
|
|||||||
plan_render_target(plan),
|
plan_render_target(plan),
|
||||||
plan.language,
|
plan.language,
|
||||||
data.published_posts,
|
data.published_posts,
|
||||||
translations_by_post_language,
|
render_data.translations_by_post_language,
|
||||||
localized_posts_by_language,
|
render_data.localized_posts_by_language,
|
||||||
on_output
|
on_output
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -720,8 +675,8 @@ defmodule BDS.Generation do
|
|||||||
plan_render_target(plan),
|
plan_render_target(plan),
|
||||||
plan.language,
|
plan.language,
|
||||||
data.published_posts,
|
data.published_posts,
|
||||||
translations_by_post_language,
|
render_data.translations_by_post_language,
|
||||||
localized_posts_by_language,
|
render_data.localized_posts_by_language,
|
||||||
on_output
|
on_output
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -729,7 +684,12 @@ defmodule BDS.Generation do
|
|||||||
end
|
end
|
||||||
|
|
||||||
archive_outputs =
|
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 =
|
urls =
|
||||||
(core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs)
|
(core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs)
|
||||||
@@ -770,14 +730,14 @@ defmodule BDS.Generation do
|
|||||||
defp build_validation_sitemap_artifacts(
|
defp build_validation_sitemap_artifacts(
|
||||||
plan,
|
plan,
|
||||||
data,
|
data,
|
||||||
published_route_posts,
|
published_posts,
|
||||||
generated_file_updated_at,
|
generated_file_updated_at,
|
||||||
on_progress
|
on_progress
|
||||||
) do
|
) do
|
||||||
main_paths =
|
main_paths =
|
||||||
build_validation_route_paths(
|
build_validation_route_paths(
|
||||||
plan,
|
plan,
|
||||||
published_route_posts,
|
published_posts,
|
||||||
data.published_list_posts,
|
data.published_list_posts,
|
||||||
data.post_index,
|
data.post_index,
|
||||||
nil
|
nil
|
||||||
@@ -1111,12 +1071,26 @@ defmodule BDS.Generation do
|
|||||||
{language, build_generation_post_index(posts)}
|
{language, build_generation_post_index(posts)}
|
||||||
end)
|
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,
|
data: data,
|
||||||
translations_by_post_language: translations_by_post_language,
|
translations_by_post_language: translations_by_post_language,
|
||||||
localized_posts_by_language: localized_posts_by_language,
|
localized_posts_by_language: localized_posts_by_language,
|
||||||
localized_list_posts_by_language: localized_list_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
|
end
|
||||||
|
|
||||||
@@ -1125,7 +1099,7 @@ defmodule BDS.Generation do
|
|||||||
|
|
||||||
main_root =
|
main_root =
|
||||||
if targeted_plan.request_root_routes do
|
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)
|
build_root_outputs(plan, plan.language, posts)
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
@@ -1228,7 +1202,7 @@ defmodule BDS.Generation do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp build_targeted_archive_outputs(plan, render_data, targeted_plan, builder) do
|
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 =
|
language_outputs =
|
||||||
Enum.flat_map(additional_languages(plan), fn language ->
|
Enum.flat_map(additional_languages(plan), fn language ->
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ defmodule BDS.Generation.Data do
|
|||||||
|> Enum.uniq_by(& &1.id)
|
|> Enum.uniq_by(& &1.id)
|
||||||
|> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)})
|
|> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)})
|
||||||
|
|
||||||
{published_route_posts, translations_by_post} =
|
translations_by_post =
|
||||||
build_generation_route_posts(
|
build_generation_translations(
|
||||||
plan.project_id,
|
plan.project_id,
|
||||||
project_data_dir,
|
project_data_dir,
|
||||||
published_posts,
|
published_posts,
|
||||||
@@ -87,7 +87,6 @@ defmodule BDS.Generation.Data do
|
|||||||
project_data_dir: project_data_dir,
|
project_data_dir: project_data_dir,
|
||||||
published_posts: published_posts,
|
published_posts: published_posts,
|
||||||
published_list_posts: published_list_posts,
|
published_list_posts: published_list_posts,
|
||||||
published_route_posts: published_route_posts,
|
|
||||||
translations_by_post: translations_by_post,
|
translations_by_post: translations_by_post,
|
||||||
post_index: build_generation_post_index(published_list_posts)
|
post_index: build_generation_post_index(published_list_posts)
|
||||||
}
|
}
|
||||||
@@ -121,18 +120,13 @@ defmodule BDS.Generation.Data do
|
|||||||
post_language = String.downcase(to_string(post.language || ""))
|
post_language = String.downcase(to_string(post.language || ""))
|
||||||
effective_language = if post_language == "", do: main, else: post_language
|
effective_language = if post_language == "", do: main, else: post_language
|
||||||
|
|
||||||
cond do
|
if effective_language == target do
|
||||||
is_binary(Map.get(post, :translation_source_slug)) ->
|
post
|
||||||
post
|
else
|
||||||
|
case Map.get(translations_by_post_language, {post.id, target_language}) do
|
||||||
effective_language == target ->
|
nil -> post
|
||||||
post
|
translation -> build_localized_subtree_variant(post, translation)
|
||||||
|
end
|
||||||
true ->
|
|
||||||
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)
|
end)
|
||||||
end
|
end
|
||||||
@@ -231,7 +225,7 @@ defmodule BDS.Generation.Data do
|
|||||||
read_snapshot(full_path, fallback_post, &build_post_snapshot/2)
|
read_snapshot(full_path, fallback_post, &build_post_snapshot/2)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_generation_route_posts(
|
defp build_generation_translations(
|
||||||
project_id,
|
project_id,
|
||||||
project_data_dir,
|
project_data_dir,
|
||||||
published_posts,
|
published_posts,
|
||||||
@@ -268,17 +262,7 @@ defmodule BDS.Generation.Data do
|
|||||||
end)
|
end)
|
||||||
|> Map.new(fn {post_id, translations} -> {post_id, Enum.reverse(translations)} end)
|
|> Map.new(fn {post_id, translations} -> {post_id, Enum.reverse(translations)} end)
|
||||||
|
|
||||||
route_posts =
|
translations_by_post
|
||||||
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}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp published_translation_snapshot(project_data_dir, %Translation{} = translation) do
|
defp published_translation_snapshot(project_data_dir, %Translation{} = translation) do
|
||||||
@@ -357,31 +341,6 @@ defmodule BDS.Generation.Data do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_published_translation_variant(post, translation) do
|
|
||||||
%{
|
|
||||||
id: translation.id,
|
|
||||||
project_id: post.project_id,
|
|
||||||
title: translation.title,
|
|
||||||
slug: "#{post.slug}.#{translation.language}",
|
|
||||||
excerpt: translation.excerpt,
|
|
||||||
content: nil,
|
|
||||||
status: :published,
|
|
||||||
author: post.author,
|
|
||||||
created_at: post.created_at,
|
|
||||||
updated_at: translation.updated_at,
|
|
||||||
published_at: translation.published_at || post.published_at,
|
|
||||||
file_path: translation.file_path,
|
|
||||||
tags: post.tags,
|
|
||||||
categories: post.categories,
|
|
||||||
template_slug: post.template_slug,
|
|
||||||
language: translation.language,
|
|
||||||
do_not_translate: post.do_not_translate,
|
|
||||||
translation_source_slug: post.slug,
|
|
||||||
translation_canonical_language: post.language,
|
|
||||||
translation_file_path: translation.file_path
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_localized_subtree_variant(post, translation) do
|
defp build_localized_subtree_variant(post, translation) do
|
||||||
%{
|
%{
|
||||||
post
|
post
|
||||||
|
|||||||
@@ -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, ""), do: post_output_path(post)
|
||||||
def route_post_output_path(post, route_language), do: post_output_path(post, route_language)
|
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) :: [
|
@spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [
|
||||||
String.t()
|
String.t()
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,15 +28,15 @@ defmodule BDS.Generation.Validation do
|
|||||||
@spec build_post_timestamp_checks(String.t(), [map()], map()) :: [map()]
|
@spec build_post_timestamp_checks(String.t(), [map()], map()) :: [map()]
|
||||||
def build_post_timestamp_checks(
|
def build_post_timestamp_checks(
|
||||||
project_data_dir,
|
project_data_dir,
|
||||||
published_route_posts,
|
published_posts,
|
||||||
generated_file_updated_at
|
generated_file_updated_at
|
||||||
) do
|
) do
|
||||||
build_timestamp_checks(
|
build_timestamp_checks(
|
||||||
project_data_dir,
|
project_data_dir,
|
||||||
published_route_posts,
|
published_posts,
|
||||||
generated_file_updated_at,
|
generated_file_updated_at,
|
||||||
&BDS.Generation.Paths.post_output_path/1,
|
&BDS.Generation.Paths.post_output_path/1,
|
||||||
fn post -> Map.get(post, :translation_file_path) || post.file_path end
|
& &1.file_path
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -70,17 +70,20 @@ defmodule BDS.MacBundle do
|
|||||||
frameworks = Path.join(contents, "Frameworks")
|
frameworks = Path.join(contents, "Frameworks")
|
||||||
rel = Path.join(resources, "rel")
|
rel = Path.join(resources, "rel")
|
||||||
|
|
||||||
File.rm_rf!(app)
|
# Validate before rm_rf! so a refused build leaves any previous bundle intact.
|
||||||
Enum.each([macos, resources, frameworks], &File.mkdir_p!/1)
|
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),
|
with {:ok, _} <- File.cp_r(release_dir, rel),
|
||||||
:ok <- File.cp(icns_path, Path.join(resources, "#{@icon_name}.icns")),
|
: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, "Info.plist"), info_plist(version: version)),
|
||||||
:ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"),
|
:ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"),
|
||||||
:ok <- write_launcher(Path.join(macos, @executable)),
|
:ok <- write_launcher(Path.join(macos, @executable)),
|
||||||
:ok <- maybe_relocate_dylibs(rel, frameworks, opts),
|
:ok <- maybe_relocate_dylibs(rel, frameworks, opts),
|
||||||
:ok <- maybe_codesign(app, opts) do
|
:ok <- maybe_codesign(app, opts) do
|
||||||
{:ok, app}
|
{:ok, app}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -130,8 +133,10 @@ defmodule BDS.MacBundle do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Fail the build unless the bundle is self-contained: no Mach-O under the `.app`
|
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
|
may reference a Homebrew/local (`/opt/homebrew`, `/usr/local`) path, and every
|
||||||
hard requirement — the app must run on a Mac without Homebrew installed.
|
`@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()) ::
|
@spec verify_standalone(String.t()) ::
|
||||||
:ok | {:error, {:external_refs, [{String.t(), String.t()}]}}
|
:ok | {:error, {:external_refs, [{String.t(), String.t()}]}}
|
||||||
@@ -141,7 +146,8 @@ defmodule BDS.MacBundle do
|
|||||||
|> macho_files()
|
|> macho_files()
|
||||||
|> Enum.flat_map(fn file ->
|
|> Enum.flat_map(fn file ->
|
||||||
external_refs(file, ["-L"], &Dylibs.parse_otool/1) ++
|
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)
|
end)
|
||||||
|
|
||||||
if offenders == [], do: :ok, else: {:error, {:external_refs, offenders}}
|
if offenders == [], do: :ok, else: {:error, {:external_refs, offenders}}
|
||||||
@@ -154,6 +160,50 @@ defmodule BDS.MacBundle do
|
|||||||
end
|
end
|
||||||
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 """
|
@doc """
|
||||||
Compute a `../`-style relative path from `from_dir` to `to` (both absolute),
|
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`.
|
e.g. the path from the wx NIF's directory up to `Contents/Frameworks`.
|
||||||
|
|||||||
@@ -103,16 +103,43 @@ defmodule BDS.MacBundle.Dylibs do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Build `{old, new}` rewrites for a binary's `otool -L` dependency list: every
|
Resolve an `@rpath/<name>` dependency to absolute candidate paths using the
|
||||||
external dep becomes `<prefix>/<basename>`. System (`/usr/lib`, `/System`) and
|
referencing binary's `LC_RPATH` list, the way dyld would: each rpath entry is
|
||||||
already-relocatable (`@rpath`/`@loader_path`/`@executable_path`) deps are
|
tried in order with `@loader_path` expanded to the binary's own directory.
|
||||||
dropped. Rewriting by basename — not by the exact source path — is what makes
|
`@executable_path` rpaths are skipped (not resolvable from a library), and
|
||||||
this catch every absolute spelling of the same library.
|
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()}]
|
@spec resolve_rpath_dep(String.t(), [String.t()], String.t()) :: [String.t()]
|
||||||
def relink_changes(deps, prefix) when is_list(deps) do
|
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
|
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)
|
|> Enum.map(fn dep -> {dep, prefix <> "/" <> Path.basename(dep)} end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -135,9 +162,10 @@ defmodule BDS.MacBundle.Dylibs do
|
|||||||
|
|
||||||
with {:ok, externals} <- collect(nifs, %{}) do
|
with {:ok, externals} <- collect(nifs, %{}) do
|
||||||
reals = materialize(externals, frameworks_dir)
|
reals = materialize(externals, frameworks_dir)
|
||||||
|
bundled = Map.keys(externals)
|
||||||
|
|
||||||
with :ok <- relink_each(reals, fn _real -> "@loader_path" end, set_id: true),
|
with :ok <- relink_each(reals, fn _real -> "@loader_path" end, bundled, set_id: true),
|
||||||
:ok <- relink_each(nifs, prefix_for, set_id: false) do
|
:ok <- relink_each(nifs, prefix_for, bundled, set_id: false) do
|
||||||
{:ok, reals}
|
{:ok, reals}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -147,10 +175,9 @@ defmodule BDS.MacBundle.Dylibs do
|
|||||||
# basename — different absolute spellings of the same leaf collapse here).
|
# basename — different absolute spellings of the same leaf collapse here).
|
||||||
defp collect(binaries, acc) do
|
defp collect(binaries, acc) do
|
||||||
Enum.reduce_while(binaries, {:ok, acc}, fn bin, {:ok, acc} ->
|
Enum.reduce_while(binaries, {:ok, acc}, fn bin, {:ok, acc} ->
|
||||||
case otool(bin) do
|
case external_deps(bin) do
|
||||||
{:ok, deps} ->
|
{:ok, deps} ->
|
||||||
deps
|
deps
|
||||||
|> Enum.filter(&external?/1)
|
|
||||||
|> Enum.reduce_while({:ok, acc}, fn dep, {:ok, acc} ->
|
|> Enum.reduce_while({:ok, acc}, fn dep, {:ok, acc} ->
|
||||||
base = Path.basename(dep)
|
base = Path.basename(dep)
|
||||||
|
|
||||||
@@ -174,6 +201,42 @@ defmodule BDS.MacBundle.Dylibs do
|
|||||||
end)
|
end)
|
||||||
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
|
# Copy each external once (deduped by device+inode, which follows symlinks to
|
||||||
# the real file); additional basenames for the same file become symlinks so
|
# the real file); additional basenames for the same file become symlinks so
|
||||||
# dyld loads a single image. Returns the real (non-symlink) copies.
|
# 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}
|
{stat.major_device, stat.inode}
|
||||||
end
|
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)
|
set_id? = Keyword.fetch!(opts, :set_id)
|
||||||
|
|
||||||
Enum.reduce_while(binaries, :ok, fn binary, :ok ->
|
Enum.reduce_while(binaries, :ok, fn binary, :ok ->
|
||||||
with :ok <- maybe_set_id(binary, set_id?),
|
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}
|
{:cont, :ok}
|
||||||
else
|
else
|
||||||
error -> {:halt, error}
|
error -> {:halt, error}
|
||||||
@@ -225,9 +288,9 @@ defmodule BDS.MacBundle.Dylibs do
|
|||||||
run("install_name_tool", id_args(binary, "@loader_path/" <> Path.basename(binary)))
|
run("install_name_tool", id_args(binary, "@loader_path/" <> Path.basename(binary)))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp relink(binary, prefix) do
|
defp relink(binary, prefix, bundled) do
|
||||||
with {:ok, deps} <- otool(binary),
|
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)
|
strip_external_rpaths(binary)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -142,4 +142,106 @@ defmodule BDS.Maintenance do
|
|||||||
|
|
||||||
{:ok, %{diff_reports: diff_reports, orphan_reports: orphan_reports}}
|
{:ok, %{diff_reports: diff_reports, orphan_reports: orphan_reports}}
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ defmodule BDS.Metadata do
|
|||||||
@typedoc "Attribute map for `update_project_metadata/2`."
|
@typedoc "Attribute map for `update_project_metadata/2`."
|
||||||
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
|
@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()}
|
@spec get_project_metadata(String.t()) :: {:ok, metadata_state()}
|
||||||
def get_project_metadata(project_id) do
|
def get_project_metadata(project_id) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
@@ -313,6 +318,8 @@ defmodule BDS.Metadata do
|
|||||||
}
|
}
|
||||||
end
|
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
|
defp normalize_project_metadata_attrs(attrs, project) do
|
||||||
%{
|
%{
|
||||||
name: attr(attrs, :name) || project.name,
|
name: attr(attrs, :name) || project.name,
|
||||||
@@ -328,6 +335,26 @@ defmodule BDS.Metadata do
|
|||||||
semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false,
|
semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false,
|
||||||
blog_languages: normalize_language_list(attr(attrs, :blog_languages) || [])
|
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
|
end
|
||||||
|
|
||||||
defp normalize_category_settings(settings) do
|
defp normalize_category_settings(settings) do
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ defmodule BDS.ReleasePackaging do
|
|||||||
app_release_source = Keyword.fetch!(opts, :app_release_source)
|
app_release_source = Keyword.fetch!(opts, :app_release_source)
|
||||||
mcp_release_source = Keyword.fetch!(opts, :mcp_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(app_release_source, metadata.app_root),
|
||||||
:ok <- copy_release(mcp_release_source, metadata.resources_root),
|
:ok <- copy_release(mcp_release_source, metadata.resources_root),
|
||||||
:ok <- write_manifest(metadata),
|
:ok <- write_manifest(metadata),
|
||||||
@@ -65,6 +66,20 @@ defmodule BDS.ReleasePackaging do
|
|||||||
end
|
end
|
||||||
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(platform) when platform in [:macos, :linux, :windows], do: platform
|
||||||
defp normalize_platform(:darwin), do: :macos
|
defp normalize_platform(:darwin), do: :macos
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ defmodule BDS.Scripting.ApiDocs do
|
|||||||
params: [],
|
params: [],
|
||||||
returns: "table | nil"
|
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",
|
module: "app",
|
||||||
name: "notify_renderer_ready",
|
name: "notify_renderer_ready",
|
||||||
@@ -762,7 +770,8 @@ defmodule BDS.Scripting.ApiDocs do
|
|||||||
%{
|
%{
|
||||||
module: "meta",
|
module: "meta",
|
||||||
name: "update_project_metadata",
|
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}],
|
params: [%{name: "updates", type: "table", required: true}],
|
||||||
returns: "ProjectMetadata | nil"
|
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_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_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),
|
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),
|
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),
|
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),
|
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.
|
and opt-in.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@type source :: String.t()
|
@type source :: String.t()
|
||||||
@type entrypoint :: String.t()
|
@type entrypoint :: String.t()
|
||||||
@type args :: [term()]
|
@type args :: [term()]
|
||||||
@type progress_event :: map()
|
@type progress_event :: map()
|
||||||
@type progress_callback :: (progress_event() -> any())
|
@type progress_callback :: (progress_event() -> any())
|
||||||
|
@type output_callback :: (String.t() -> any())
|
||||||
@type execution_option ::
|
@type execution_option ::
|
||||||
{:timeout, non_neg_integer() | :infinity}
|
{:timeout, non_neg_integer() | :infinity}
|
||||||
| {:max_reductions, pos_integer() | :none}
|
| {:max_reductions, pos_integer() | :none}
|
||||||
| {:spawn_opts, [term()]}
|
| {:spawn_opts, [term()]}
|
||||||
| {:on_progress, progress_callback()}
|
| {:on_progress, progress_callback()}
|
||||||
|
| {:on_output, output_callback()}
|
||||||
| {:capabilities, map()}
|
| {:capabilities, map()}
|
||||||
|
|
||||||
@callback validate(source()) :: :ok | {:error, term()}
|
@callback validate(source()) :: :ok | {:error, term()}
|
||||||
@@ -48,12 +52,65 @@ defmodule BDS.Scripting.Lua do
|
|||||||
end
|
end
|
||||||
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
|
defp initial_state(opts) do
|
||||||
state = :luerl_sandbox.init()
|
state = :luerl_sandbox.init()
|
||||||
capabilities = Keyword.get(opts, :capabilities, %{})
|
capabilities = Keyword.get(opts, :capabilities, %{})
|
||||||
|
|
||||||
with {:ok, state} <- :luerl.set_table_keys_dec(["bds"], %{}, state),
|
with {:ok, state} <- :luerl.set_table_keys_dec(["bds"], %{}, state),
|
||||||
{:ok, state} <- install_progress_callback(state, Keyword.get(opts, :on_progress)),
|
{: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} <- install_capabilities(state, capabilities) do
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
@@ -85,6 +142,15 @@ defmodule BDS.Scripting.Lua do
|
|||||||
defp install_progress_callback(_state, callback),
|
defp install_progress_callback(_state, callback),
|
||||||
do: {:error, {:invalid_progress_callback, 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 capabilities in [%{}, []], do: {:ok, state}
|
||||||
|
|
||||||
defp install_capabilities(state, capabilities) when is_map(capabilities) do
|
defp install_capabilities(state, capabilities) when is_map(capabilities) do
|
||||||
|
|||||||
@@ -13,29 +13,139 @@ defmodule BDS.Server do
|
|||||||
@doc """
|
@doc """
|
||||||
Resolves the boot mode from the `BDS_MODE` environment variable:
|
Resolves the boot mode from the `BDS_MODE` environment variable:
|
||||||
`"server"` (headless + SSH daemon), `"tui"` (headless + SSH daemon +
|
`"server"` (headless + SSH daemon), `"tui"` (headless + SSH daemon +
|
||||||
a TUI attached to the launching terminal), anything else is desktop.
|
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
|
@spec mode(String.t() | nil) :: :desktop | :server | :tui | :cli
|
||||||
def mode(value \\ System.get_env("BDS_MODE"))
|
def mode(value \\ System.get_env("BDS_MODE"))
|
||||||
|
|
||||||
def mode(value) when is_binary(value) do
|
def mode(value) when is_binary(value) do
|
||||||
case String.downcase(value) do
|
case String.downcase(value) do
|
||||||
"server" -> :server
|
"server" -> :server
|
||||||
"tui" -> :tui
|
"tui" -> :tui
|
||||||
|
"cli" -> :cli
|
||||||
_other -> :desktop
|
_other -> :desktop
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def mode(nil), do: :desktop
|
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 """
|
@doc """
|
||||||
Whether the HTTP endpoint requires the desktop webview auth token. Only
|
Whether the HTTP endpoint requires the desktop webview auth token. Only
|
||||||
desktop mode does: in server/tui mode the endpoint stays loopback-only
|
desktop mode does: in server/tui mode the endpoint stays loopback-only
|
||||||
and clients arrive through the key-authenticated SSH tunnel, which the
|
and clients arrive through the key-authenticated SSH tunnel, which the
|
||||||
per-boot webview token would otherwise lock out.
|
per-boot webview token would otherwise lock out.
|
||||||
"""
|
"""
|
||||||
@spec desktop_auth_required?(:desktop | :server | :tui) :: boolean()
|
@spec desktop_auth_required?(:desktop | :server | :tui | :cli) :: boolean()
|
||||||
def desktop_auth_required?(mode \\ mode())
|
def desktop_auth_required?(mode \\ effective_mode())
|
||||||
def desktop_auth_required?(:desktop), do: true
|
def desktop_auth_required?(:desktop), do: true
|
||||||
def desktop_auth_required?(_mode), do: false
|
def desktop_auth_required?(_mode), do: false
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ defmodule BDS.Settings do
|
|||||||
Persistence layer for global application settings stored as key-value pairs in the database.
|
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.Persistence
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Settings.Setting
|
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
|
@spec get_global_setting(String.t()) :: String.t() | nil
|
||||||
def get_global_setting(key) do
|
def get_global_setting(key) do
|
||||||
case Repo.get(Setting, key) do
|
case Repo.get(Setting, key) do
|
||||||
|
|||||||
1200
lib/bds/tui.ex
1200
lib/bds/tui.ex
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
|
||||||
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_search(filters.search)
|
||||||
|> maybe_where_year(filters.year)
|
|> maybe_where_year(filters.year)
|
||||||
|> maybe_where_month(filters.month)
|
|> maybe_where_month(filters.month)
|
||||||
|
|> maybe_where_day(filters.day)
|
||||||
|> maybe_where_all_tags(filters.tags)
|
|> maybe_where_all_tags(filters.tags)
|
||||||
|> maybe_where_all_categories(filters.categories)
|
|> maybe_where_all_categories(filters.categories)
|
||||||
end
|
end
|
||||||
@@ -424,6 +425,25 @@ defmodule BDS.UI.Sidebar do
|
|||||||
)
|
)
|
||||||
end
|
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, []), do: query
|
||||||
|
|
||||||
defp maybe_where_all_tags(query, tags), do: where_all_in_json_array(query, tags, :tags)
|
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_search(filters.search)
|
||||||
|> maybe_where_media_year(filters.year)
|
|> maybe_where_media_year(filters.year)
|
||||||
|> maybe_where_media_month(filters.month)
|
|> maybe_where_media_month(filters.month)
|
||||||
|
|> maybe_where_media_day(filters.day)
|
||||||
|> maybe_where_all_media_tags(filters.tags)
|
|> maybe_where_all_media_tags(filters.tags)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -667,6 +688,22 @@ defmodule BDS.UI.Sidebar do
|
|||||||
)
|
)
|
||||||
end
|
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, []), do: query
|
||||||
|
|
||||||
defp maybe_where_all_media_tags(query, tags),
|
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)),
|
search: normalize_string(BDS.MapUtils.attr(params, :search)),
|
||||||
year: normalize_integer(BDS.MapUtils.attr(params, :year)),
|
year: normalize_integer(BDS.MapUtils.attr(params, :year)),
|
||||||
month: normalize_integer(BDS.MapUtils.attr(params, :month)),
|
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)),
|
tags: normalize_string_list(BDS.MapUtils.attr(params, :tags)),
|
||||||
categories: normalize_string_list(BDS.MapUtils.attr(params, :categories)),
|
categories: normalize_string_list(BDS.MapUtils.attr(params, :categories)),
|
||||||
display_limit:
|
display_limit:
|
||||||
@@ -1045,6 +1083,7 @@ defmodule BDS.UI.Sidebar do
|
|||||||
search: nil,
|
search: nil,
|
||||||
year: nil,
|
year: nil,
|
||||||
month: nil,
|
month: nil,
|
||||||
|
day: nil,
|
||||||
tags: [],
|
tags: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
display_limit: @default_page_size
|
display_limit: @default_page_size
|
||||||
@@ -1052,8 +1091,8 @@ defmodule BDS.UI.Sidebar do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp filter_active?(filters) do
|
defp filter_active?(filters) do
|
||||||
present?(filters.search) or not is_nil(filters.year) or filters.tags != [] or
|
present?(filters.search) or not is_nil(filters.year) or not is_nil(filters.day) or
|
||||||
filters.categories != []
|
filters.tags != [] or filters.categories != []
|
||||||
end
|
end
|
||||||
|
|
||||||
defp post_search_blob(post) do
|
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
|
||||||
37
mix.exs
37
mix.exs
@@ -2,6 +2,8 @@ defmodule BDS.MixProject do
|
|||||||
use Mix.Project
|
use Mix.Project
|
||||||
|
|
||||||
def project do
|
def project do
|
||||||
|
ensure_xla_compiler_flags()
|
||||||
|
|
||||||
[
|
[
|
||||||
app: :bds,
|
app: :bds,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -15,6 +17,22 @@ defmodule BDS.MixProject do
|
|||||||
]
|
]
|
||||||
end
|
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
|
def application do
|
||||||
[
|
[
|
||||||
extra_applications: [:logger, :wx, :ssl],
|
extra_applications: [:logger, :wx, :ssl],
|
||||||
@@ -32,6 +50,7 @@ defmodule BDS.MixProject do
|
|||||||
{:ecto_sqlite3, "~> 0.24"},
|
{:ecto_sqlite3, "~> 0.24"},
|
||||||
{:luerl, "~> 1.5"},
|
{:luerl, "~> 1.5"},
|
||||||
{:jason, "~> 1.4"},
|
{:jason, "~> 1.4"},
|
||||||
|
{:optimus, "~> 0.5"},
|
||||||
{:mdex, "~> 0.13"},
|
{:mdex, "~> 0.13"},
|
||||||
{:liquex, "~> 0.13.1"},
|
{:liquex, "~> 0.13.1"},
|
||||||
{:plug, "~> 1.18"},
|
{:plug, "~> 1.18"},
|
||||||
@@ -43,11 +62,21 @@ defmodule BDS.MixProject do
|
|||||||
{:ex_ratatui, "~> 0.11"},
|
{:ex_ratatui, "~> 0.11"},
|
||||||
{:image, "~> 0.67"},
|
{:image, "~> 0.67"},
|
||||||
{:nx, "~> 0.10"},
|
{: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
|
# Apple Silicon GPU (Metal) acceleration for embedding inference. Ships
|
||||||
# precompiled MLX binaries; the Neural backend prefers it on arm64 macOS
|
# precompiled MLX binaries; the Neural backend prefers it on arm64 macOS
|
||||||
# and falls back to EXLA-CPU elsewhere (SPECGAPS A1-14c).
|
# and falls back to EXLA-CPU elsewhere (SPECGAPS A1-14c). Apple-only, so
|
||||||
{:emlx, "~> 0.2.0"},
|
# 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"},
|
{:bumblebee, "~> 0.6.3"},
|
||||||
{:hnswlib, "~> 0.1.7"},
|
{:hnswlib, "~> 0.1.7"},
|
||||||
{:stemex, "~> 0.2.1"},
|
{:stemex, "~> 0.2.1"},
|
||||||
@@ -102,4 +131,6 @@ defmodule BDS.MixProject do
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp macos?, do: :os.type() == {:unix, :darwin}
|
||||||
end
|
end
|
||||||
|
|||||||
1
mix.lock
1
mix.lock
@@ -62,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_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"},
|
"nx_signal": {:hex, :nx_signal, "0.2.0", "e1ca0318877b17c81ce8906329f5125f1e2361e4c4235a5baac8a95ee88ea98e", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "7247e5e18a177a59c4cb5355952900c62fdeadeb2bad02a9a34237b68744e2bb"},
|
||||||
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
|
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
|
||||||
|
"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": {: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_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
|
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:566
|
#: lib/bds/ui/sidebar.ex:586
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archiv"
|
msgstr "Archiv"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:566
|
#: lib/bds/ui/sidebar.ex:586
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:566
|
#: lib/bds/ui/sidebar.ex:586
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archivo"
|
msgstr "Archivo"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:566
|
#: lib/bds/ui/sidebar.ex:586
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archives"
|
msgstr "Archives"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:566
|
#: lib/bds/ui/sidebar.ex:586
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archivio"
|
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/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:566
|
#: lib/bds/ui/sidebar.ex:586
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
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_visible: !hasClass("[data-testid='assistant-shell']", "is-hidden"),
|
||||||
assistant_width: document.querySelector("[data-testid='assistant-shell']")?.getBoundingClientRect().width ?? 0,
|
assistant_width: document.querySelector("[data-testid='assistant-shell']")?.getBoundingClientRect().width ?? 0,
|
||||||
panel_visible: !hasClass(".panel-shell", "is-hidden"),
|
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']"),
|
editor_title: text("[data-testid='editor-title']"),
|
||||||
activity_labels: texts("[data-testid='activity-button']", (node) => node.getAttribute("aria-label")),
|
activity_labels: texts("[data-testid='activity-button']", (node) => node.getAttribute("aria-label")),
|
||||||
sidebar_sections: texts("[data-testid='sidebar-section-title']", (node) => node.textContent.trim()),
|
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,
|
-- 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
|
-- 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
|
-- 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).
|
-- Neighbour search is HNSW (hnswlib).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,17 @@ invariant MultiLanguageRoutes {
|
|||||||
-- Each language subtree gets its own feeds and archives
|
-- 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 {
|
invariant CanonicalBaseUrlConfigured {
|
||||||
for generation in SiteGenerations:
|
for generation in SiteGenerations:
|
||||||
generation.base_url != ""
|
generation.base_url != ""
|
||||||
|
|||||||
@@ -249,6 +249,8 @@ invariant PanelTabFallback {
|
|||||||
-- Tasks tab: last 10 tasks, newest first, with progress/cancel.
|
-- Tasks tab: last 10 tasks, newest first, with progress/cancel.
|
||||||
-- Tasks with shared group_id are collapsible groups showing aggregate progress.
|
-- Tasks with shared group_id are collapsible groups showing aggregate progress.
|
||||||
-- Output tab: log entries with copy-all button.
|
-- 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).
|
-- Post Links tab: backlinks (posts linking here) + outlinks (posts linked from here).
|
||||||
-- Each entry clickable, opens linked post as pinned tab.
|
-- Each entry clickable, opens linked post as pinned tab.
|
||||||
-- Git Log tab: file-level git history for active post/media (up to 50 entries).
|
-- 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 reporting is cooperative and flows through the supplied
|
||||||
-- progress sink rather than ambient global side effects.
|
-- 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
|
@guarantee BatchCancellation
|
||||||
-- Managed utility and transform jobs can be cancelled by the host
|
-- Managed utility and transform jobs can be cancelled by the host
|
||||||
-- operator boundary.
|
-- operator boundary.
|
||||||
|
|||||||
@@ -30,7 +30,24 @@ surface ServerRuntimeSurface {
|
|||||||
|
|
||||||
rule ModeResolution {
|
rule ModeResolution {
|
||||||
when: ApplicationStarted(mode)
|
when: ApplicationStarted(mode)
|
||||||
-- BDS_MODE environment variable decides the mode once per boot
|
-- 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)
|
ensures: BootMode.created(kind: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
121
specs/tui.allium
121
specs/tui.allium
@@ -4,7 +4,7 @@
|
|||||||
-- Distilled from: lib/bds/tui.ex, lib/bds/ui/sidebar.ex, lib/bds/ui/post_editor/*.ex
|
-- Distilled from: lib/bds/tui.ex, lib/bds/ui/sidebar.ex, lib/bds/ui/post_editor/*.ex
|
||||||
|
|
||||||
entity TuiState {
|
entity TuiState {
|
||||||
view: posts | media | templates | scripts | tags
|
view: posts | media | templates | scripts | tags | settings | git
|
||||||
focus: sidebar | editor
|
focus: sidebar | editor
|
||||||
selected_index: Integer
|
selected_index: Integer
|
||||||
editing_post: Boolean
|
editing_post: Boolean
|
||||||
@@ -33,9 +33,12 @@ rule SidebarNavigation {
|
|||||||
|
|
||||||
rule OpenEntry {
|
rule OpenEntry {
|
||||||
when: TuiKeyPressed(code: "enter")
|
when: TuiKeyPressed(code: "enter")
|
||||||
-- Posts open in the editor (title + textarea seeded from the
|
-- Posts open in the editor by default (title + textarea seeded from
|
||||||
-- persisted form). Images open in the terminal image preview.
|
-- the persisted form; syntax highlighting and word wrap on; ctrl+e
|
||||||
-- Other entities report that terminal editing is not available yet.
|
-- 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()
|
ensures: TuiState.editing_post.updated()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +50,27 @@ rule SaveAndPublish {
|
|||||||
ensures: PostPersisted()
|
ensures: PostPersisted()
|
||||||
}
|
}
|
||||||
|
|
||||||
rule WrappedPreview {
|
rule EditorMode {
|
||||||
when: TuiKeyPressed(code: "e", modifiers: "ctrl")
|
when: TuiKeyPressed(code: "e", modifiers: "ctrl")
|
||||||
-- The editing textarea cannot soft-wrap (upstream ratatui-textarea
|
-- ctrl+e toggles between the syntax-highlighted editor (the default
|
||||||
-- limitation), so ctrl+e toggles a read-only word-wrapped Markdown
|
-- mode when opening a post) and the read-only rendered-Markdown
|
||||||
-- preview of the current draft; keys in preview never edit content.
|
-- 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()
|
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 {
|
rule AiQuickAction {
|
||||||
when: TuiKeyPressed(code: "g", modifiers: "ctrl")
|
when: TuiKeyPressed(code: "g", modifiers: "ctrl")
|
||||||
-- One-shot AI post analysis (title/excerpt suggestions). Gated by
|
-- One-shot AI post analysis (title/excerpt suggestions). Gated by
|
||||||
@@ -63,6 +79,39 @@ rule AiQuickAction {
|
|||||||
ensures: AiSuggestionsRequestedOrRefused()
|
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 {
|
rule CommandPrompt {
|
||||||
when: TuiKeyPressed(code: ":")
|
when: TuiKeyPressed(code: ":")
|
||||||
-- Vi-style prompt: ":" opens a filterable list of the parameterless
|
-- Vi-style prompt: ":" opens a filterable list of the parameterless
|
||||||
@@ -77,6 +126,62 @@ rule CommandPrompt {
|
|||||||
ensures: CommandListShownOrExecuted()
|
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 {
|
rule ReportPanels {
|
||||||
when: ShellTaskCompleted(route: "metadata_diff" | "site_validation")
|
when: ShellTaskCompleted(route: "metadata_diff" | "site_validation")
|
||||||
-- Completed metadata diff and site validation tasks open a
|
-- Completed metadata diff and site validation tasks open a
|
||||||
|
|||||||
401
test/bds/cli_test.exs
Normal file
401
test/bds/cli_test.exs
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
defmodule BDS.CLITest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
import ExUnit.CaptureIO
|
||||||
|
|
||||||
|
alias BDS.CLI
|
||||||
|
alias BDS.CLI.Commands
|
||||||
|
alias BDS.CliSync.Notification
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||||
|
Repo.delete_all(Notification)
|
||||||
|
|
||||||
|
temp_dir = Path.join(System.tmp_dir!(), "bds-cli-test-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, temp_dir: temp_dir}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_active_project(temp_dir, name \\ "CLI Test") do
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: name, data_path: temp_dir})
|
||||||
|
{:ok, project} = BDS.Projects.set_active_project(project.id)
|
||||||
|
project
|
||||||
|
end
|
||||||
|
|
||||||
|
defp notifications do
|
||||||
|
Repo.all(Notification)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "argv decoding" do
|
||||||
|
test "splits on the unit separator and drops empties" do
|
||||||
|
assert CLI.env_argv("post" <> <<0x1F>> <> "--title" <> <<0x1F>> <> "Hello" <> <<0x1F>>) ==
|
||||||
|
["post", "--title", "Hello"]
|
||||||
|
|
||||||
|
assert CLI.env_argv(nil) == []
|
||||||
|
assert CLI.env_argv("") == []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "parser" do
|
||||||
|
test "parses every subcommand" do
|
||||||
|
optimus = CLI.optimus()
|
||||||
|
|
||||||
|
assert {:ok, [:rebuild], %{flags: %{incremental: true}}} =
|
||||||
|
Optimus.parse(optimus, ["rebuild", "--incremental"])
|
||||||
|
|
||||||
|
assert {:ok, [:repair], %{args: %{part: "post-links"}}} =
|
||||||
|
Optimus.parse(optimus, ["repair", "post-links"])
|
||||||
|
|
||||||
|
assert {:error, [:repair], _errors} = Optimus.parse(optimus, ["repair", "nonsense"])
|
||||||
|
|
||||||
|
assert {:ok, [:render], %{flags: %{force: true, incremental: false}}} =
|
||||||
|
Optimus.parse(optimus, ["render", "--force"])
|
||||||
|
|
||||||
|
assert {:ok, [:upload], _result} = Optimus.parse(optimus, ["upload"])
|
||||||
|
assert {:ok, [:push], _result} = Optimus.parse(optimus, ["push"])
|
||||||
|
assert {:ok, [:pull], _result} = Optimus.parse(optimus, ["pull"])
|
||||||
|
|
||||||
|
assert {:ok, [:post], %{options: %{title: "Hi", tags: "a,b"}}} =
|
||||||
|
Optimus.parse(optimus, ["post", "--title", "Hi", "--tags", "a,b"])
|
||||||
|
|
||||||
|
assert {:ok, [:media], %{args: %{file: "img.jpg"}, options: %{language: "de"}}} =
|
||||||
|
Optimus.parse(optimus, ["media", "img.jpg", "--language", "de"])
|
||||||
|
|
||||||
|
assert {:ok, [:gallery], %{options: %{title: "Trip"}, unknown: ["a.jpg", "b.jpg"]}} =
|
||||||
|
Optimus.parse(optimus, ["gallery", "--title", "Trip", "a.jpg", "b.jpg"])
|
||||||
|
|
||||||
|
assert {:ok, [:config, :get], %{args: %{key: "ui.theme"}}} =
|
||||||
|
Optimus.parse(optimus, ["config", "get", "ui.theme"])
|
||||||
|
|
||||||
|
assert {:ok, [:config, :set], %{args: %{key: "k", value: "v"}}} =
|
||||||
|
Optimus.parse(optimus, ["config", "set", "k", "v"])
|
||||||
|
|
||||||
|
assert {:ok, [:config, :list], _result} = Optimus.parse(optimus, ["config", "list"])
|
||||||
|
assert {:ok, [:project, :list], _result} = Optimus.parse(optimus, ["project", "list"])
|
||||||
|
|
||||||
|
assert {:ok, [:project, :add], %{args: %{path: "/tmp/x"}, options: %{name: "X"}}} =
|
||||||
|
Optimus.parse(optimus, ["project", "add", "/tmp/x", "--name", "X"])
|
||||||
|
|
||||||
|
assert {:ok, [:project, :switch], %{args: %{project: "blog"}}} =
|
||||||
|
Optimus.parse(optimus, ["project", "switch", "blog"])
|
||||||
|
|
||||||
|
assert {:ok, [:tui], _result} = Optimus.parse(optimus, ["tui"])
|
||||||
|
|
||||||
|
assert {:ok, [:lua], %{args: %{script: "cleanup"}, unknown: ["arg1"]}} =
|
||||||
|
Optimus.parse(optimus, ["lua", "cleanup", "arg1"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "help exits 0 and unknown commands exit 1" do
|
||||||
|
{help_code, help_output} = with_io(fn -> CLI.run(["--help"]) end)
|
||||||
|
assert help_code == 0
|
||||||
|
assert help_output =~ "rebuild"
|
||||||
|
assert help_output =~ "gallery"
|
||||||
|
|
||||||
|
{code, _output} = with_io(:stderr, fn -> CLI.run(["frobnicate"]) end)
|
||||||
|
assert code == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "config commands" do
|
||||||
|
test "set, get, and list operate on the global settings store", %{temp_dir: _temp_dir} do
|
||||||
|
assert {:ok, "cli.test.key = hello"} =
|
||||||
|
Commands.dispatch([:config, :set], %{args: %{key: "cli.test.key", value: "hello"}})
|
||||||
|
|
||||||
|
assert {:ok, "hello"} =
|
||||||
|
Commands.dispatch([:config, :get], %{args: %{key: "cli.test.key"}})
|
||||||
|
|
||||||
|
{:ok, output} = with_io(fn -> Commands.dispatch([:config, :list], %{}) end)
|
||||||
|
assert output =~ "cli.test.key=hello"
|
||||||
|
|
||||||
|
assert {:error, message} =
|
||||||
|
Commands.dispatch([:config, :get], %{args: %{key: "cli.test.missing"}})
|
||||||
|
|
||||||
|
assert message =~ "not set"
|
||||||
|
|
||||||
|
assert Enum.any?(
|
||||||
|
notifications(),
|
||||||
|
&(&1.entity_type == "setting" and &1.entity_id == "cli.test.key" and
|
||||||
|
&1.from_cli == true)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "project commands" do
|
||||||
|
test "add, switch, and list manage the project registry", %{temp_dir: temp_dir} do
|
||||||
|
assert {:ok, added_message} =
|
||||||
|
Commands.dispatch([:project, :add], %{
|
||||||
|
args: %{path: temp_dir},
|
||||||
|
options: %{name: "CLI Added"}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert added_message =~ "Added project"
|
||||||
|
|
||||||
|
project =
|
||||||
|
Enum.find(BDS.Projects.list_projects(), &(&1.name == "CLI Added"))
|
||||||
|
|
||||||
|
assert project
|
||||||
|
|
||||||
|
assert {:ok, "Active project: CLI Added"} =
|
||||||
|
Commands.dispatch([:project, :switch], %{args: %{project: project.slug}})
|
||||||
|
|
||||||
|
{:ok, output} = with_io(fn -> Commands.dispatch([:project, :list], %{}) end)
|
||||||
|
assert output =~ "CLI Added"
|
||||||
|
assert output =~ "* " <> project.id
|
||||||
|
|
||||||
|
assert {:error, message} =
|
||||||
|
Commands.dispatch([:project, :switch], %{args: %{project: "no-such-project"}})
|
||||||
|
|
||||||
|
assert message =~ "No project matches"
|
||||||
|
|
||||||
|
entity_types = Enum.map(notifications(), & &1.entity_type)
|
||||||
|
assert "project" in entity_types
|
||||||
|
end
|
||||||
|
|
||||||
|
test "add rejects a missing directory" do
|
||||||
|
assert {:error, message} =
|
||||||
|
Commands.dispatch([:project, :add], %{
|
||||||
|
args: %{path: "/nonexistent/bds-cli"},
|
||||||
|
options: %{}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert message =~ "not a directory"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "post command" do
|
||||||
|
test "creates a post from options and writes a cli notification", %{temp_dir: temp_dir} do
|
||||||
|
create_active_project(temp_dir)
|
||||||
|
|
||||||
|
{{:ok, message}, output} =
|
||||||
|
with_io(fn ->
|
||||||
|
Commands.dispatch([:post], %{
|
||||||
|
flags: %{no_translate: true},
|
||||||
|
options: %{
|
||||||
|
title: "From the CLI",
|
||||||
|
content: "Hello from the command line",
|
||||||
|
language: "en",
|
||||||
|
tags: "cli,test",
|
||||||
|
categories: "notes"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert message =~ "Created post"
|
||||||
|
assert output == "" or output =~ "translation"
|
||||||
|
|
||||||
|
post =
|
||||||
|
Repo.one!(
|
||||||
|
from post in BDS.Posts.Post, where: post.title == "From the CLI"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert post.tags == ["cli", "test"]
|
||||||
|
assert post.categories == ["notes"]
|
||||||
|
assert post.language == "en"
|
||||||
|
assert post.status == :draft
|
||||||
|
|
||||||
|
assert Enum.any?(
|
||||||
|
notifications(),
|
||||||
|
&(&1.entity_type == "post" and &1.entity_id == post.id and &1.action == :created)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates a post from JSON on stdin", %{temp_dir: temp_dir} do
|
||||||
|
create_active_project(temp_dir)
|
||||||
|
|
||||||
|
json =
|
||||||
|
Jason.encode!(%{
|
||||||
|
"title" => "Stdin Post",
|
||||||
|
"content" => "Body from stdin",
|
||||||
|
"language" => "en",
|
||||||
|
"tags" => ["json", "cli"]
|
||||||
|
})
|
||||||
|
|
||||||
|
{{:ok, message}, _output} =
|
||||||
|
with_io([input: json], fn ->
|
||||||
|
Commands.dispatch([:post], %{flags: %{stdin: true, no_translate: true}, options: %{}})
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert message =~ "Created post"
|
||||||
|
|
||||||
|
post = Repo.one!(from post in BDS.Posts.Post, where: post.title == "Stdin Post")
|
||||||
|
assert post.tags == ["json", "cli"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires a title", %{temp_dir: temp_dir} do
|
||||||
|
create_active_project(temp_dir)
|
||||||
|
|
||||||
|
assert {:error, message} = Commands.dispatch([:post], %{flags: %{}, options: %{}})
|
||||||
|
assert message =~ "--title is required"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "media command" do
|
||||||
|
test "imports a file and reports enrichment best-effort", %{temp_dir: temp_dir} do
|
||||||
|
create_active_project(temp_dir)
|
||||||
|
|
||||||
|
source = Path.join(temp_dir, "cli-image.png")
|
||||||
|
File.write!(source, "fake png bytes")
|
||||||
|
|
||||||
|
{result, _output} =
|
||||||
|
with_io(fn ->
|
||||||
|
Commands.dispatch([:media], %{args: %{file: source}, options: %{}})
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, message} = result
|
||||||
|
assert message =~ "Imported media"
|
||||||
|
|
||||||
|
assert Enum.any?(notifications(), &(&1.entity_type == "media" and &1.action == :created))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects a missing file", %{temp_dir: temp_dir} do
|
||||||
|
create_active_project(temp_dir)
|
||||||
|
|
||||||
|
assert {:error, message} =
|
||||||
|
Commands.dispatch([:media], %{
|
||||||
|
args: %{file: Path.join(temp_dir, "missing.png")},
|
||||||
|
options: %{}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert message =~ "No such file"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "rebuild command" do
|
||||||
|
test "incremental rebuild diffs and applies from the filesystem", %{temp_dir: temp_dir} do
|
||||||
|
create_active_project(temp_dir)
|
||||||
|
|
||||||
|
{result, _output} =
|
||||||
|
with_io(fn ->
|
||||||
|
Commands.dispatch([:rebuild], %{flags: %{incremental: true}})
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, message} = result
|
||||||
|
assert message =~ "differences"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "gallery and tui guards" do
|
||||||
|
test "gallery without images errors", %{temp_dir: temp_dir} do
|
||||||
|
create_active_project(temp_dir)
|
||||||
|
|
||||||
|
assert {:error, message} =
|
||||||
|
Commands.dispatch([:gallery], %{
|
||||||
|
flags: %{},
|
||||||
|
options: %{title: "Trip"},
|
||||||
|
unknown: []
|
||||||
|
})
|
||||||
|
|
||||||
|
assert message =~ "image files"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tui is delegated to the launcher script" do
|
||||||
|
assert {:error, message} = Commands.dispatch([:tui], %{})
|
||||||
|
assert message =~ "launcher"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "lua command" do
|
||||||
|
test "rejects unknown and non-utility scripts", %{temp_dir: temp_dir} do
|
||||||
|
project = create_active_project(temp_dir)
|
||||||
|
|
||||||
|
assert {:error, message} =
|
||||||
|
Commands.dispatch([:lua], %{args: %{script: "missing"}, unknown: []})
|
||||||
|
|
||||||
|
assert message =~ "No script"
|
||||||
|
|
||||||
|
{:ok, script} =
|
||||||
|
BDS.Scripts.create_script(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Macro Script",
|
||||||
|
kind: :macro,
|
||||||
|
content: "function render() return \"x\" end",
|
||||||
|
entrypoint: "render"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:error, macro_message} =
|
||||||
|
Commands.dispatch([:lua], %{args: %{script: script.slug}, unknown: []})
|
||||||
|
|
||||||
|
assert macro_message =~ "macro"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "runs a utility script synchronously", %{temp_dir: temp_dir} do
|
||||||
|
project = create_active_project(temp_dir)
|
||||||
|
|
||||||
|
{:ok, script} =
|
||||||
|
BDS.Scripts.create_script(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Task Script",
|
||||||
|
kind: :utility,
|
||||||
|
content: "function main() return \"done-42\" end",
|
||||||
|
entrypoint: "main"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, message} =
|
||||||
|
Commands.dispatch([:lua], %{args: %{script: script.slug}, unknown: []})
|
||||||
|
|
||||||
|
assert message =~ "done-42"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "full rebuild step sharing" do
|
||||||
|
test "the CLI and GUI share the canonical full-rebuild sequence" do
|
||||||
|
names = Enum.map(BDS.Maintenance.full_rebuild_steps("project-id"), & &1.name)
|
||||||
|
|
||||||
|
assert names == [
|
||||||
|
"Rebuild Posts From Files",
|
||||||
|
"Rebuild Media From Files",
|
||||||
|
"Rebuild Scripts From Files",
|
||||||
|
"Rebuild Templates From Files",
|
||||||
|
"Rebuild Post Links",
|
||||||
|
"Regenerate Missing Thumbnails",
|
||||||
|
"Rebuild Embedding Index"
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "installer" do
|
||||||
|
test "installs a shim pointing at the release launcher", %{temp_dir: temp_dir} do
|
||||||
|
release_root = Path.join(temp_dir, "release")
|
||||||
|
launcher = Path.join([release_root, "cli", "bin", "bds-cli"])
|
||||||
|
File.mkdir_p!(Path.dirname(launcher))
|
||||||
|
File.write!(launcher, "#!/bin/sh\n")
|
||||||
|
|
||||||
|
bin_dir = Path.join(temp_dir, "local-bin")
|
||||||
|
|
||||||
|
assert {:ok, installed} =
|
||||||
|
BDS.CLI.Install.install(bin_dir: bin_dir, release_root: release_root)
|
||||||
|
|
||||||
|
assert installed == Path.join(bin_dir, "bds-cli")
|
||||||
|
content = File.read!(installed)
|
||||||
|
assert content =~ launcher
|
||||||
|
assert content =~ ~s(exec ")
|
||||||
|
assert Bitwise.band(File.stat!(installed).mode, 0o111) != 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reports no_release outside a packaged release", %{temp_dir: temp_dir} do
|
||||||
|
assert {:error, :no_release} =
|
||||||
|
BDS.CLI.Install.install(
|
||||||
|
bin_dir: Path.join(temp_dir, "bin"),
|
||||||
|
release_root: Path.join(temp_dir, "not-a-release")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "settings form action" do
|
||||||
|
test "the data section exposes the install action and run_action handles it" do
|
||||||
|
fields = BDS.UI.SettingsForm.load("data", "nonexistent-project").fields
|
||||||
|
action = Enum.find(fields, &(&1.type == :action))
|
||||||
|
assert action.key == "install_cli"
|
||||||
|
|
||||||
|
# Outside a packaged release the action reports the error message.
|
||||||
|
assert {:error, message} = BDS.UI.SettingsForm.run_action("install_cli")
|
||||||
|
assert message =~ "packaged application"
|
||||||
|
|
||||||
|
assert {:error, _unknown} = BDS.UI.SettingsForm.run_action("nope")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -129,6 +129,31 @@ defmodule BDS.Desktop.AutomationTest do
|
|||||||
assert automation_process_counts() == baseline
|
assert automation_process_counts() == baseline
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@tag timeout: 120_000
|
||||||
|
test "cmd-J panel tab strip is visible and switches between tasks and output (issue #20)" do
|
||||||
|
{:ok, session} = Automation.start_session()
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
Automation.stop_session(session)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert :ok = Automation.press(session, "Meta+J")
|
||||||
|
|
||||||
|
snapshot = await(session, & &1.panel_visible)
|
||||||
|
assert snapshot.panel_visible == true
|
||||||
|
|
||||||
|
# The tab strip must offer both tabs and be tall enough to actually see
|
||||||
|
# and hit them — issue #20 was the strip collapsing to an unusable sliver.
|
||||||
|
assert "Tasks" in snapshot.panel_tab_labels
|
||||||
|
assert "Output" in snapshot.panel_tab_labels
|
||||||
|
assert snapshot.panel_tabs_height >= 20
|
||||||
|
|
||||||
|
assert :ok = Automation.click(session, "button[phx-value-tab='output']")
|
||||||
|
|
||||||
|
snapshot = await(session, &(&1.panel_active_tab == "Output"))
|
||||||
|
assert snapshot.panel_active_tab == "Output"
|
||||||
|
end
|
||||||
|
|
||||||
@tag timeout: 120_000
|
@tag timeout: 120_000
|
||||||
test "automation dispatches native menu actions into the liveview shell" do
|
test "automation dispatches native menu actions into the liveview shell" do
|
||||||
{:ok, session} = Automation.start_session()
|
{:ok, session} = Automation.start_session()
|
||||||
|
|||||||
@@ -907,6 +907,120 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ ~s(data-tab-id="#{created_post.id}")
|
assert html =~ ~s(data-tab-id="#{created_post.id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "tasks panel shows a cancel button for running tasks and cancels them" do
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
{:ok, task} =
|
||||||
|
BDS.Tasks.submit_task("Long Running Task", fn _report ->
|
||||||
|
receive do
|
||||||
|
:finish -> :ok
|
||||||
|
after
|
||||||
|
30_000 -> :ok
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
send(view.pid, :refresh_task_status)
|
||||||
|
|
||||||
|
html = render_click(view, "select_panel_tab", %{"tab" => "tasks"})
|
||||||
|
|
||||||
|
assert html =~ "Long Running Task"
|
||||||
|
assert html =~ ~s(data-testid="cancel-task-button")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element(~s{[data-testid="cancel-task-button"]})
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert Enum.find(BDS.Tasks.list_tasks(), &(&1.id == task.id)).status == :cancelled
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tasks panel groups tasks by group_id and toggles the group collapsed" do
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
blocking = fn _report ->
|
||||||
|
receive do
|
||||||
|
:finish -> :ok
|
||||||
|
after
|
||||||
|
30_000 -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, _t1} = BDS.Tasks.submit_task("First Grouped", blocking, %{group_id: "grp", group_name: "Batch"})
|
||||||
|
{:ok, _t2} = BDS.Tasks.submit_task("Second Grouped", blocking, %{group_id: "grp", group_name: "Batch"})
|
||||||
|
|
||||||
|
send(view.pid, :refresh_task_status)
|
||||||
|
|
||||||
|
html = render_click(view, "select_panel_tab", %{"tab" => "tasks"})
|
||||||
|
|
||||||
|
# One group row for both tasks, expanded by default (task names also
|
||||||
|
# appear in the status bar, hence the <strong> row scoping)
|
||||||
|
assert html =~ "Batch (2)"
|
||||||
|
assert html =~ "<strong>First Grouped</strong>"
|
||||||
|
assert html =~ "<strong>Second Grouped</strong>"
|
||||||
|
|
||||||
|
html = render_click(view, "toggle_task_group", %{"group" => "grp"})
|
||||||
|
|
||||||
|
assert html =~ "Batch (2)"
|
||||||
|
refute html =~ "<strong>First Grouped</strong>"
|
||||||
|
refute html =~ "<strong>Second Grouped</strong>"
|
||||||
|
|
||||||
|
html = render_click(view, "toggle_task_group", %{"group" => "grp"})
|
||||||
|
|
||||||
|
assert html =~ "<strong>First Grouped</strong>"
|
||||||
|
assert html =~ "<strong>Second Grouped</strong>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "output panel copy button pushes the full log text to the clipboard" do
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
send(view.pid, {:editor_output, "First", "line one", nil, "info"})
|
||||||
|
send(view.pid, {:editor_output, "Second", "line two", nil, "error"})
|
||||||
|
|
||||||
|
html = render_click(view, "select_panel_tab", %{"tab" => "output"})
|
||||||
|
|
||||||
|
assert html =~ ~s(data-testid="copy-output-button")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element(~s{[data-testid="copy-output-button"]})
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
# Chronological order (oldest first), entries joined by a blank line.
|
||||||
|
assert_push_event(view, "clipboard", %{text: "line one\n\nline two"})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "blogmark transform output lands in the output panel and auto-opens it", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
{:ok, _script} =
|
||||||
|
Scripts.create_and_publish_script(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Logging Transform",
|
||||||
|
kind: :transform,
|
||||||
|
entrypoint: "main",
|
||||||
|
content:
|
||||||
|
"function main(data, ctx) bds.app.log('transform ran') print('still ran') return data end"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
url =
|
||||||
|
"bds2://new-post?title=" <>
|
||||||
|
URI.encode_www_form("Logged Blogmark") <>
|
||||||
|
"&url=" <> URI.encode_www_form("https://example.com/logged")
|
||||||
|
|
||||||
|
send(view.pid, {:blogmark_deep_link, url})
|
||||||
|
_html = render(view)
|
||||||
|
|
||||||
|
assigns = :sys.get_state(view.pid).socket.assigns
|
||||||
|
messages = Enum.map(assigns.output_entries, & &1.message)
|
||||||
|
|
||||||
|
assert "transform ran" in messages
|
||||||
|
assert "still ran" in messages
|
||||||
|
|
||||||
|
# Like the old app: noteworthy script output opens the panel on Output.
|
||||||
|
assert assigns.workbench.panel.visible == true
|
||||||
|
assert assigns.workbench.panel.active_tab == :output
|
||||||
|
end
|
||||||
|
|
||||||
test "blogmark deep link with a project_id switches to that project and imports there",
|
test "blogmark deep link with a project_id switches to that project and imports there",
|
||||||
%{project: active} do
|
%{project: active} do
|
||||||
{:ok, other} =
|
{:ok, other} =
|
||||||
@@ -3427,6 +3541,115 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert output_html =~ "Automatic AI actions stay gated by airplane mode"
|
assert output_html =~ "Automatic AI actions stay gated by airplane mode"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "selecting an internal insert link result pushes the link and closes the overlay", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
{:ok, target} =
|
||||||
|
Posts.create_post(%{project_id: project.id, title: "Link Target", content: "Target body"})
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
Posts.create_post(%{project_id: project.id, title: "Editing Post", content: "Body"})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[phx-click='open_overlay'][phx-value-kind='insert_link']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "insert-modal"
|
||||||
|
|
||||||
|
render_click(view, "overlay_select_result", %{"id" => target.id})
|
||||||
|
assert_push_event(view, "post-editor-insert-content", %{id: _id, content: content})
|
||||||
|
assert content =~ "Link Target"
|
||||||
|
|
||||||
|
refute render(view) =~ "insert-modal"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "inserting an external link pushes the link and closes the overlay", %{project: project} do
|
||||||
|
{:ok, post} =
|
||||||
|
Posts.create_post(%{project_id: project.id, title: "Editing Post", content: "Body"})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[phx-click='open_overlay'][phx-value-kind='insert_link']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "insert-modal"
|
||||||
|
|
||||||
|
render_click(view, "overlay_set_tab", %{"tab" => "external"})
|
||||||
|
|
||||||
|
render_change(view, "overlay_update_form", %{
|
||||||
|
"overlay" => %{"url" => "https://example.com", "text" => "Example"}
|
||||||
|
})
|
||||||
|
|
||||||
|
render_click(view, "overlay_insert_external", %{})
|
||||||
|
assert_push_event(view, "post-editor-insert-content", %{content: "[Example](https://example.com)"})
|
||||||
|
|
||||||
|
refute render(view) =~ "insert-modal"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "selecting an insert media result pushes the syntax and closes the overlay", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
temp_dir =
|
||||||
|
Path.join(System.tmp_dir!(), "bds-shell-live-#{System.unique_integer([:positive])}")
|
||||||
|
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
media_source_path = Path.join(temp_dir, "insert-media.jpg")
|
||||||
|
File.write!(media_source_path, "fake image body")
|
||||||
|
|
||||||
|
{:ok, media} =
|
||||||
|
Media.import_media(%{
|
||||||
|
project_id: project.id,
|
||||||
|
source_path: media_source_path,
|
||||||
|
title: "Insert Media"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
Posts.create_post(%{project_id: project.id, title: "Editing Post", content: "Body"})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[phx-click='open_overlay'][phx-value-kind='insert_media']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "insert-modal"
|
||||||
|
|
||||||
|
render_click(view, "overlay_set_search", %{"overlay" => %{"query" => "Insert"}})
|
||||||
|
render_click(view, "overlay_select_result", %{"id" => media.id})
|
||||||
|
|
||||||
|
assert_push_event(view, "post-editor-insert-content", %{content: content})
|
||||||
|
assert content =~ "bds-media://#{media.id}"
|
||||||
|
|
||||||
|
refute render(view) =~ "insert-modal"
|
||||||
|
end
|
||||||
|
|
||||||
test "ai suggestions overlay fetches async results for posts when online", %{project: project} do
|
test "ai suggestions overlay fetches async results for posts when online", %{project: project} do
|
||||||
Application.put_env(:bds, :test_pid, self())
|
Application.put_env(:bds, :test_pid, self())
|
||||||
|
|
||||||
@@ -5943,6 +6166,38 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert reloaded.version == 1
|
assert reloaded.version == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "script editor run streams print and bds.app.log output to the output panel", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
{:ok, script} =
|
||||||
|
Scripts.create_script(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Output Test",
|
||||||
|
kind: :utility,
|
||||||
|
content:
|
||||||
|
"function main() print('from-print') bds.app.log('from-log') return 'done' end"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "scripts",
|
||||||
|
"id" => script.id,
|
||||||
|
"title" => script.title,
|
||||||
|
"subtitle" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element(".scripts-run-button")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
entries = :sys.get_state(view.pid).socket.assigns.output_entries
|
||||||
|
messages = Enum.map(entries, & &1.message)
|
||||||
|
|
||||||
|
assert "from-print" in messages
|
||||||
|
assert "from-log" in messages
|
||||||
|
end
|
||||||
|
|
||||||
test "script editor check syntax validates lua content without saving", %{project: project} do
|
test "script editor check syntax validates lua content without saving", %{project: project} do
|
||||||
{:ok, script} =
|
{:ok, script} =
|
||||||
Scripts.create_script(%{
|
Scripts.create_script(%{
|
||||||
|
|||||||
@@ -38,29 +38,45 @@ defmodule BDS.Embeddings.Backends.NeuralTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "accelerator selection (NativeAcceleratedExecution)" do
|
describe "accelerator selection (NativeAcceleratedExecution)" do
|
||||||
|
# select_accelerator(configured, emlx_available?, exla_available?, apple_silicon?)
|
||||||
test "auto prefers Apple GPU (EMLX) when available on Apple Silicon" do
|
test "auto prefers Apple GPU (EMLX) when available on Apple Silicon" do
|
||||||
assert Neural.select_accelerator(:auto, true, true) == :emlx
|
assert Neural.select_accelerator(:auto, true, true, true) == :emlx
|
||||||
end
|
end
|
||||||
|
|
||||||
test "auto falls back to EXLA-CPU off Apple Silicon" do
|
test "auto falls back to EXLA-CPU off Apple Silicon" do
|
||||||
assert Neural.select_accelerator(:auto, true, false) == :exla
|
assert Neural.select_accelerator(:auto, true, true, false) == :exla
|
||||||
end
|
end
|
||||||
|
|
||||||
test "auto falls back to EXLA-CPU when EMLX is unavailable" do
|
test "auto falls back to EXLA-CPU when EMLX is unavailable" do
|
||||||
assert Neural.select_accelerator(:auto, false, true) == :exla
|
assert Neural.select_accelerator(:auto, false, true, true) == :exla
|
||||||
|
end
|
||||||
|
|
||||||
|
test "auto uses EMLX when EXLA is not shipped (pruned macOS bundle)" do
|
||||||
|
assert Neural.select_accelerator(:auto, true, false, true) == :emlx
|
||||||
|
assert Neural.select_accelerator(:auto, true, false, false) == :emlx
|
||||||
end
|
end
|
||||||
|
|
||||||
test "explicit :exla is honoured even on Apple Silicon with EMLX present" do
|
test "explicit :exla is honoured even on Apple Silicon with EMLX present" do
|
||||||
assert Neural.select_accelerator(:exla, true, true) == :exla
|
assert Neural.select_accelerator(:exla, true, true, true) == :exla
|
||||||
|
end
|
||||||
|
|
||||||
|
test "explicit :exla degrades to EMLX when EXLA is unavailable" do
|
||||||
|
assert Neural.select_accelerator(:exla, true, false, true) == :emlx
|
||||||
end
|
end
|
||||||
|
|
||||||
test "explicit :emlx is honoured when available" do
|
test "explicit :emlx is honoured when available" do
|
||||||
assert Neural.select_accelerator(:emlx, true, true) == :emlx
|
assert Neural.select_accelerator(:emlx, true, true, true) == :emlx
|
||||||
assert Neural.select_accelerator(:emlx, true, false) == :emlx
|
assert Neural.select_accelerator(:emlx, true, true, false) == :emlx
|
||||||
end
|
end
|
||||||
|
|
||||||
test "explicit :emlx degrades to EXLA when EMLX is unavailable" do
|
test "explicit :emlx degrades to EXLA when EMLX is unavailable" do
|
||||||
assert Neural.select_accelerator(:emlx, false, true) == :exla
|
assert Neural.select_accelerator(:emlx, false, true, true) == :exla
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no accelerator available selects :none" do
|
||||||
|
assert Neural.select_accelerator(:auto, false, false, true) == :none
|
||||||
|
assert Neural.select_accelerator(:exla, false, false, false) == :none
|
||||||
|
assert Neural.select_accelerator(:emlx, false, false, false) == :none
|
||||||
end
|
end
|
||||||
|
|
||||||
test "defn options map each accelerator to its native compiler" do
|
test "defn options map each accelerator to its native compiler" do
|
||||||
|
|||||||
@@ -1473,6 +1473,123 @@ defmodule BDS.GenerationTest do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "validate_site reports no missing pages for main-language translations of foreign-language posts",
|
||||||
|
%{project: project, temp_dir: temp_dir} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
public_url: "https://example.com/blog",
|
||||||
|
main_language: "de",
|
||||||
|
blog_languages: ["de", "en"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "English Original",
|
||||||
|
content: "English body",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
created_at = DateTime.to_unix(~U[2026-04-15 12:00:00Z])
|
||||||
|
|
||||||
|
Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id),
|
||||||
|
set: [created_at: created_at, updated_at: created_at]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:ok, _translation} =
|
||||||
|
Posts.upsert_post_translation(post.id, "de", %{
|
||||||
|
title: "Deutsches Original",
|
||||||
|
content: "Deutscher Inhalt"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, published_post} = Posts.publish_post(post.id)
|
||||||
|
assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:core, :single])
|
||||||
|
assert {:ok, report} = BDS.Generation.validate_site(project.id, [:core, :single])
|
||||||
|
|
||||||
|
assert report.missing_url_paths == []
|
||||||
|
assert report.extra_url_paths == []
|
||||||
|
assert report.updated_post_url_paths == []
|
||||||
|
|
||||||
|
canonical_html =
|
||||||
|
File.read!(
|
||||||
|
Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post)])
|
||||||
|
)
|
||||||
|
|
||||||
|
english_html =
|
||||||
|
File.read!(
|
||||||
|
Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post, "en")])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert canonical_html =~ "Deutscher Inhalt"
|
||||||
|
assert english_html =~ "English body"
|
||||||
|
|
||||||
|
sitemap_xml = File.read!(Path.join([temp_dir, "html", "sitemap.xml"]))
|
||||||
|
|
||||||
|
refute sitemap_xml =~ "english-original.de"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "main-language list pages and feeds use main-language translations of foreign-language posts",
|
||||||
|
%{project: project, temp_dir: temp_dir} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
public_url: "https://example.com/blog",
|
||||||
|
main_language: "de",
|
||||||
|
blog_languages: ["de", "en"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "English List Original",
|
||||||
|
content: "English list body",
|
||||||
|
language: "en",
|
||||||
|
categories: ["notes"],
|
||||||
|
tags: ["Elixir"]
|
||||||
|
})
|
||||||
|
|
||||||
|
created_at = DateTime.to_unix(~U[2026-04-15 12:00:00Z])
|
||||||
|
|
||||||
|
Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id),
|
||||||
|
set: [created_at: created_at, updated_at: created_at]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:ok, _translation} =
|
||||||
|
Posts.upsert_post_translation(post.id, "de", %{
|
||||||
|
title: "Deutsche Listenfassung",
|
||||||
|
content: "Deutscher Listeninhalt"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _published_post} = Posts.publish_post(post.id)
|
||||||
|
|
||||||
|
assert {:ok, _result} =
|
||||||
|
BDS.Generation.generate_site(project.id, [:core, :single, :category, :tag, :date])
|
||||||
|
|
||||||
|
read_output = fn segments -> File.read!(Path.join([temp_dir, "html" | segments])) end
|
||||||
|
|
||||||
|
for segments <- [
|
||||||
|
["index.html"],
|
||||||
|
["rss.xml"],
|
||||||
|
["atom.xml"],
|
||||||
|
["category", "notes", "index.html"],
|
||||||
|
["tag", "Elixir", "index.html"],
|
||||||
|
["2026", "index.html"]
|
||||||
|
] do
|
||||||
|
html = read_output.(segments)
|
||||||
|
assert html =~ "Deutsche Listenfassung", "expected translated title in #{Path.join(segments)}"
|
||||||
|
refute html =~ "English List Original", "unexpected original title in #{Path.join(segments)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
for segments <- [
|
||||||
|
["en", "index.html"],
|
||||||
|
["en", "rss.xml"],
|
||||||
|
["en", "category", "notes", "index.html"]
|
||||||
|
] do
|
||||||
|
html = read_output.(segments)
|
||||||
|
assert html =~ "English List Original", "expected original title in #{Path.join(segments)}"
|
||||||
|
refute html =~ "Deutsche Listenfassung", "unexpected translated title in #{Path.join(segments)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "validate_site follows old language subtree expectations and combined sitemap output", %{
|
test "validate_site follows old language subtree expectations and combined sitemap output", %{
|
||||||
project: project,
|
project: project,
|
||||||
temp_dir: temp_dir
|
temp_dir: temp_dir
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ defmodule BDS.MacBundleTest do
|
|||||||
alias BDS.MacBundle.Dylibs
|
alias BDS.MacBundle.Dylibs
|
||||||
alias BDS.MacBundle.Icon
|
alias BDS.MacBundle.Icon
|
||||||
|
|
||||||
|
# Give a copied keg dylib a controlled id so verify_standalone only sees the
|
||||||
|
# reference under test, not the keg's absolute LC_ID_DYLIB.
|
||||||
|
defp fix_id!(dylib, id \\ nil) do
|
||||||
|
{_out, 0} =
|
||||||
|
System.cmd(
|
||||||
|
"install_name_tool",
|
||||||
|
Dylibs.id_args(dylib, id || "@loader_path/" <> Path.basename(dylib)),
|
||||||
|
stderr_to_stdout: true
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
# Parse "#RRGGBB" into {r, g, b} for property assertions in tests.
|
# Parse "#RRGGBB" into {r, g, b} for property assertions in tests.
|
||||||
defp rgb("#" <> hex) do
|
defp rgb("#" <> hex) do
|
||||||
{r, g, b} = {String.slice(hex, 0, 2), String.slice(hex, 2, 2), String.slice(hex, 4, 2)}
|
{r, g, b} = {String.slice(hex, 0, 2), String.slice(hex, 2, 2), String.slice(hex, 4, 2)}
|
||||||
@@ -176,6 +189,116 @@ defmodule BDS.MacBundleTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "Dylibs.resolve_rpath_dep/3" do
|
||||||
|
test "expands @loader_path rpaths against the referencing binary's directory" do
|
||||||
|
assert Dylibs.resolve_rpath_dep(
|
||||||
|
"@rpath/libsharpyuv.0.dylib",
|
||||||
|
["@loader_path/../lib", "/opt/homebrew/lib"],
|
||||||
|
"/opt/homebrew/opt/webp/lib"
|
||||||
|
) == [
|
||||||
|
"/opt/homebrew/opt/webp/lib/libsharpyuv.0.dylib",
|
||||||
|
"/opt/homebrew/lib/libsharpyuv.0.dylib"
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "resolves in-tree loader-relative rpaths (vix/exla precompiled layout)" do
|
||||||
|
assert Dylibs.resolve_rpath_dep(
|
||||||
|
"@rpath/libvips.42.dylib",
|
||||||
|
["@loader_path/precompiled_libvips/lib"],
|
||||||
|
"/app/rel/lib/vix-0.38.0/priv"
|
||||||
|
) == ["/app/rel/lib/vix-0.38.0/priv/precompiled_libvips/lib/libvips.42.dylib"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "skips @executable_path rpaths (not resolvable from a library) and non-@rpath deps" do
|
||||||
|
assert Dylibs.resolve_rpath_dep(
|
||||||
|
"@rpath/libfoo.dylib",
|
||||||
|
["@executable_path/../Frameworks"],
|
||||||
|
"/any/dir"
|
||||||
|
) == []
|
||||||
|
|
||||||
|
assert Dylibs.resolve_rpath_dep("/usr/lib/libSystem.B.dylib", ["@loader_path"], "/d") == []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Dylibs.relink_changes/3 (@rpath deps)" do
|
||||||
|
test "rewrites @rpath deps whose basename was bundled, leaves the rest" do
|
||||||
|
deps = [
|
||||||
|
"/opt/homebrew/opt/webp/lib/libwebp.7.dylib",
|
||||||
|
"@rpath/libsharpyuv.0.dylib",
|
||||||
|
"@rpath/libvips.42.dylib",
|
||||||
|
"/usr/lib/libSystem.B.dylib"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert Dylibs.relink_changes(deps, "@loader_path", ["libsharpyuv.0.dylib"]) == [
|
||||||
|
{"/opt/homebrew/opt/webp/lib/libwebp.7.dylib", "@loader_path/libwebp.7.dylib"},
|
||||||
|
{"@rpath/libsharpyuv.0.dylib", "@loader_path/libsharpyuv.0.dylib"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "MacBundle.verify_standalone/1 (@rpath resolution gate)" do
|
||||||
|
@describetag :macos_tools
|
||||||
|
|
||||||
|
# Regression for the July 2026 crash: Homebrew's libwebp references its
|
||||||
|
# sibling as @rpath/libsharpyuv.0.dylib (rpath @loader_path/../lib). A copy
|
||||||
|
# in Frameworks without the sibling must fail verification; a copy whose
|
||||||
|
# rpath resolves inside the tree must pass.
|
||||||
|
test "fails on an @rpath dep that does not resolve inside the bundle and passes when it does" do
|
||||||
|
keg_webp = "/opt/homebrew/opt/webp/lib/libwebp.7.dylib"
|
||||||
|
keg_sharpyuv = "/opt/homebrew/opt/webp/lib/libsharpyuv.0.dylib"
|
||||||
|
|
||||||
|
if File.exists?(keg_webp) and File.exists?(keg_sharpyuv) do
|
||||||
|
tmp = Path.join(System.tmp_dir!(), "bds-rpath-#{System.unique_integer([:positive])}")
|
||||||
|
on_exit(fn -> File.rm_rf!(tmp) end)
|
||||||
|
|
||||||
|
# Broken layout: libwebp in Frameworks, libsharpyuv missing entirely.
|
||||||
|
broken = Path.join(tmp, "broken/Contents/Frameworks")
|
||||||
|
File.mkdir_p!(broken)
|
||||||
|
File.cp!(keg_webp, Path.join(broken, "libwebp.7.dylib"))
|
||||||
|
File.chmod!(Path.join(broken, "libwebp.7.dylib"), 0o644)
|
||||||
|
fix_id!(Path.join(broken, "libwebp.7.dylib"))
|
||||||
|
|
||||||
|
assert {:error, {:external_refs, offenders}} =
|
||||||
|
MacBundle.verify_standalone(Path.join(tmp, "broken"))
|
||||||
|
|
||||||
|
assert Enum.any?(offenders, fn {_file, ref} -> ref == "@rpath/libsharpyuv.0.dylib" end)
|
||||||
|
|
||||||
|
# Healthy layout: @loader_path/../lib resolves next to the library, so
|
||||||
|
# placing the sibling there satisfies the reference (vix-style in-tree
|
||||||
|
# resolution must not be flagged). libsharpyuv keeps an @rpath/ self id
|
||||||
|
# — precompiled NIF deps (hnswlib, libvips, libmlx) ship exactly that,
|
||||||
|
# and a library's own LC_ID_DYLIB must never count as unresolved.
|
||||||
|
healthy = Path.join(tmp, "healthy/Contents/pkg/lib")
|
||||||
|
File.mkdir_p!(healthy)
|
||||||
|
File.cp!(keg_webp, Path.join(healthy, "libwebp.7.dylib"))
|
||||||
|
File.cp!(keg_sharpyuv, Path.join(healthy, "libsharpyuv.0.dylib"))
|
||||||
|
Enum.each(["libwebp.7.dylib", "libsharpyuv.0.dylib"], fn base ->
|
||||||
|
File.chmod!(Path.join(healthy, base), 0o644)
|
||||||
|
end)
|
||||||
|
|
||||||
|
fix_id!(Path.join(healthy, "libwebp.7.dylib"))
|
||||||
|
fix_id!(Path.join(healthy, "libsharpyuv.0.dylib"), "@rpath/libsharpyuv.0.dylib")
|
||||||
|
|
||||||
|
# Match the precompiled-NIF shape exactly: @rpath/ self id with NO
|
||||||
|
# LC_RPATH left to resolve it (hnswlib_nif.so ships like this).
|
||||||
|
sharpyuv = Path.join(healthy, "libsharpyuv.0.dylib")
|
||||||
|
|
||||||
|
{rpath_out, 0} = System.cmd("otool", ["-l", sharpyuv], stderr_to_stdout: true)
|
||||||
|
|
||||||
|
Enum.each(Dylibs.parse_rpaths(rpath_out), fn rpath ->
|
||||||
|
{_out, 0} =
|
||||||
|
System.cmd("install_name_tool", ["-delete_rpath", rpath, sharpyuv],
|
||||||
|
stderr_to_stdout: true
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert MacBundle.verify_standalone(Path.join(tmp, "healthy")) == :ok
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "Dylibs.parse_rpaths/1" do
|
describe "Dylibs.parse_rpaths/1" do
|
||||||
test "extracts LC_RPATH paths from otool -l output, ignoring other load commands" do
|
test "extracts LC_RPATH paths from otool -l output, ignoring other load commands" do
|
||||||
output = """
|
output = """
|
||||||
@@ -313,6 +436,8 @@ defmodule BDS.MacBundleTest do
|
|||||||
File.write!(Path.join(release, "bin/bds"), "#!/bin/sh\necho fake release\n")
|
File.write!(Path.join(release, "bin/bds"), "#!/bin/sh\necho fake release\n")
|
||||||
File.mkdir_p!(Path.join(release, "lib/wx-2.4/priv"))
|
File.mkdir_p!(Path.join(release, "lib/wx-2.4/priv"))
|
||||||
File.write!(Path.join(release, "lib/wx-2.4/priv/wxe_driver.so"), "fake-nif")
|
File.write!(Path.join(release, "lib/wx-2.4/priv/wxe_driver.so"), "fake-nif")
|
||||||
|
File.mkdir_p!(Path.join(release, "releases/0.1.0"))
|
||||||
|
File.write!(Path.join(release, "releases/0.1.0/env.sh"), "# env")
|
||||||
|
|
||||||
icns = Path.join(tmp, "AppIcon.icns")
|
icns = Path.join(tmp, "AppIcon.icns")
|
||||||
File.write!(icns, "fake-icns")
|
File.write!(icns, "fake-icns")
|
||||||
@@ -366,5 +491,28 @@ defmodule BDS.MacBundleTest do
|
|||||||
assert File.exists?(Path.join(app, "Contents/Resources/rel/bin/bds"))
|
assert File.exists?(Path.join(app, "Contents/Resources/rel/bin/bds"))
|
||||||
assert File.exists?(Path.join(app, "Contents/Resources/AppIcon.icns"))
|
assert File.exists?(Path.join(app, "Contents/Resources/AppIcon.icns"))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "refuses a half-assembled release and keeps the previous bundle", %{app: app} do
|
||||||
|
# A failed `mix release` leaves lib/ but no releases/<version>/env.sh.
|
||||||
|
tmp = Path.join(System.tmp_dir!(), "bds-macbundle-broken-#{System.unique_integer([:positive])}")
|
||||||
|
broken = Path.join(tmp, "rel")
|
||||||
|
File.mkdir_p!(Path.join(broken, "bin"))
|
||||||
|
File.mkdir_p!(Path.join(broken, "lib/wx-2.4/priv"))
|
||||||
|
on_exit(fn -> File.rm_rf!(tmp) end)
|
||||||
|
|
||||||
|
assert {:error, {:incomplete_release, message}} =
|
||||||
|
MacBundle.assemble(
|
||||||
|
release_dir: broken,
|
||||||
|
output_dir: Path.dirname(app),
|
||||||
|
icns_path: Path.join(tmp, "missing.icns"),
|
||||||
|
version: "0.1.0",
|
||||||
|
skip_dylibs: true,
|
||||||
|
skip_codesign: true
|
||||||
|
)
|
||||||
|
|
||||||
|
assert message =~ "mix release"
|
||||||
|
# the refused build must not have wiped the existing bundle
|
||||||
|
assert File.exists?(Path.join(app, "Contents/Resources/rel/bin/bds"))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -54,6 +54,27 @@ defmodule BDS.MetadataTest do
|
|||||||
assert loaded.blog_languages == ["de", "fr"]
|
assert loaded.blog_languages == ["de", "fr"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "update_project_metadata keeps fields that are absent from the attrs", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
BDS.Metadata.update_project_metadata(project.id, %{
|
||||||
|
description: "Keep me",
|
||||||
|
public_url: "https://example.com",
|
||||||
|
default_author: "Writer",
|
||||||
|
blog_languages: ["de"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, metadata} =
|
||||||
|
BDS.Metadata.update_project_metadata(project.id, %{pico_theme: "blue"})
|
||||||
|
|
||||||
|
assert metadata.pico_theme == "blue"
|
||||||
|
assert metadata.description == "Keep me"
|
||||||
|
assert metadata.public_url == "https://example.com"
|
||||||
|
assert metadata.default_author == "Writer"
|
||||||
|
assert metadata.blog_languages == ["de"]
|
||||||
|
end
|
||||||
|
|
||||||
test "update_project_metadata keeps committed database changes when filesystem flush fails", %{
|
test "update_project_metadata keeps committed database changes when filesystem flush fails", %{
|
||||||
project: project,
|
project: project,
|
||||||
temp_dir: temp_dir
|
temp_dir: temp_dir
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ defmodule BDS.ReleasePackagingTest do
|
|||||||
File.mkdir_p!(Path.join(mcp_release, "mcp/bin"))
|
File.mkdir_p!(Path.join(mcp_release, "mcp/bin"))
|
||||||
File.write!(Path.join(app_release, "bin/bds"), "app-release")
|
File.write!(Path.join(app_release, "bin/bds"), "app-release")
|
||||||
File.write!(Path.join(mcp_release, "mcp/bin/bds-mcp"), "mcp-release")
|
File.write!(Path.join(mcp_release, "mcp/bin/bds-mcp"), "mcp-release")
|
||||||
|
File.mkdir_p!(Path.join(app_release, "releases/0.1.0"))
|
||||||
|
File.write!(Path.join(app_release, "releases/0.1.0/env.sh"), "# env")
|
||||||
|
|
||||||
on_exit(fn -> File.rm_rf(base_dir) end)
|
on_exit(fn -> File.rm_rf(base_dir) end)
|
||||||
|
|
||||||
@@ -66,6 +68,23 @@ defmodule BDS.ReleasePackagingTest do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "package refuses a half-assembled release (no releases/*/env.sh)", context do
|
||||||
|
# A failed `mix release` can leave lib/ copied but no releases/<version>/;
|
||||||
|
# packaging that produces an app that dies on launch.
|
||||||
|
File.rm_rf!(Path.join(context.app_release, "releases"))
|
||||||
|
|
||||||
|
assert {:error, {:incomplete_release, message}} =
|
||||||
|
ReleasePackaging.package(
|
||||||
|
platform: :macos,
|
||||||
|
version: "0.1.0",
|
||||||
|
output_dir: context.output_dir,
|
||||||
|
app_release_source: context.app_release,
|
||||||
|
mcp_release_source: context.mcp_release
|
||||||
|
)
|
||||||
|
|
||||||
|
assert message =~ "mix release"
|
||||||
|
end
|
||||||
|
|
||||||
test "package creates a platform archive for redistribution", context do
|
test "package creates a platform archive for redistribution", context do
|
||||||
assert {:ok, metadata} =
|
assert {:ok, metadata} =
|
||||||
ReleasePackaging.package(
|
ReleasePackaging.package(
|
||||||
|
|||||||
@@ -34,6 +34,57 @@ defmodule BDS.Scripting.LuaTest do
|
|||||||
assert_receive {:progress, %{"phase" => "write", "current" => 2, "total" => 2}}
|
assert_receive {:progress, %{"phase" => "write", "current" => 2, "total" => 2}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "streams bds.app.log output through the on_output callback" do
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
source = """
|
||||||
|
function main()
|
||||||
|
bds.app.log('fetching')
|
||||||
|
bds.app.log('done', 42)
|
||||||
|
return 'ok'
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
|
||||||
|
callback = fn text -> send(parent, {:script_output, text}) end
|
||||||
|
|
||||||
|
assert {:ok, "ok"} =
|
||||||
|
BDS.Scripting.execute_project_script("project-id", source, "main", [],
|
||||||
|
on_output: callback
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_receive {:script_output, "fetching"}
|
||||||
|
assert_receive {:script_output, "done 42"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "routes print calls through the on_output callback" do
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
source = """
|
||||||
|
function main()
|
||||||
|
print('hello', 'world')
|
||||||
|
return 'ok'
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
|
||||||
|
callback = fn text -> send(parent, {:script_output, text}) end
|
||||||
|
|
||||||
|
assert {:ok, "ok"} = BDS.Scripting.execute(source, "main", [], on_output: callback)
|
||||||
|
|
||||||
|
assert_receive {:script_output, "hello world"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "log and print fall back to a silent sink without an on_output callback" do
|
||||||
|
source = """
|
||||||
|
function main()
|
||||||
|
bds.app.log('note')
|
||||||
|
print('hello')
|
||||||
|
return 'ok'
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert {:ok, "ok"} = BDS.Scripting.execute_project_script("project-id", source, "main", [])
|
||||||
|
end
|
||||||
|
|
||||||
test "sandbox blocks os.execute" do
|
test "sandbox blocks os.execute" do
|
||||||
source = "function main() os.execute('echo hacked') end"
|
source = "function main() os.execute('echo hacked') end"
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,157 @@ defmodule BDS.ServerTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "effective_mode/3" do
|
||||||
|
test "desktop stays desktop when a display is available" do
|
||||||
|
assert Server.effective_mode(:desktop, {:unix, :linux}, %{"DISPLAY" => ":0"}) == :desktop
|
||||||
|
|
||||||
|
assert Server.effective_mode(:desktop, {:unix, :linux}, %{"WAYLAND_DISPLAY" => "wayland-0"}) ==
|
||||||
|
:desktop
|
||||||
|
end
|
||||||
|
|
||||||
|
test "desktop falls back to the TUI on unix without a display" do
|
||||||
|
assert Server.effective_mode(:desktop, {:unix, :linux}, %{}) == :tui
|
||||||
|
assert Server.effective_mode(:desktop, {:unix, :linux}, %{"DISPLAY" => ""}) == :tui
|
||||||
|
assert Server.effective_mode(:desktop, {:unix, :freebsd}, %{"WAYLAND_DISPLAY" => ""}) == :tui
|
||||||
|
end
|
||||||
|
|
||||||
|
test "macOS keeps the GUI locally but falls back to the TUI over ssh" do
|
||||||
|
# macOS has no DISPLAY variable; a local launch always has a
|
||||||
|
# graphical session, an ssh session never does.
|
||||||
|
assert Server.effective_mode(:desktop, {:unix, :darwin}, %{}) == :desktop
|
||||||
|
|
||||||
|
assert Server.effective_mode(:desktop, {:unix, :darwin}, %{
|
||||||
|
"SSH_CONNECTION" => "10.0.0.5 52000 10.0.0.9 22"
|
||||||
|
}) == :tui
|
||||||
|
|
||||||
|
assert Server.effective_mode(:desktop, {:unix, :darwin}, %{"SSH_TTY" => "/dev/ttys003"}) ==
|
||||||
|
:tui
|
||||||
|
|
||||||
|
assert Server.effective_mode(:desktop, {:unix, :darwin}, %{"SSH_CLIENT" => "10.0.0.5"}) ==
|
||||||
|
:tui
|
||||||
|
|
||||||
|
assert Server.effective_mode(:desktop, {:unix, :darwin}, %{"SSH_CONNECTION" => ""}) ==
|
||||||
|
:desktop
|
||||||
|
end
|
||||||
|
|
||||||
|
test "linux over ssh with a forwarded display keeps the GUI" do
|
||||||
|
env = %{"SSH_CONNECTION" => "10.0.0.5 52000 10.0.0.9 22", "DISPLAY" => "localhost:10.0"}
|
||||||
|
assert Server.effective_mode(:desktop, {:unix, :linux}, env) == :desktop
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Windows never falls back" do
|
||||||
|
assert Server.effective_mode(:desktop, {:win32, :nt}, %{}) == :desktop
|
||||||
|
|
||||||
|
assert Server.effective_mode(:desktop, {:win32, :nt}, %{"SSH_CONNECTION" => "10.0.0.5"}) ==
|
||||||
|
:desktop
|
||||||
|
end
|
||||||
|
|
||||||
|
test "explicit server and tui modes are untouched" do
|
||||||
|
assert Server.effective_mode(:server, {:unix, :linux}, %{}) == :server
|
||||||
|
assert Server.effective_mode(:tui, {:unix, :linux}, %{"DISPLAY" => ":0"}) == :tui
|
||||||
|
assert Server.effective_mode(:server, {:unix, :darwin}, %{"DISPLAY" => ":0"}) == :server
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "prepare_boot_env/1" do
|
||||||
|
setup do
|
||||||
|
original = System.get_env("NO_WX")
|
||||||
|
System.delete_env("NO_WX")
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
if original, do: System.put_env("NO_WX", original), else: System.delete_env("NO_WX")
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-desktop modes disable wx so the :desktop dep boots inert" do
|
||||||
|
assert Server.prepare_boot_env(:tui, fn -> :ok end) == :tui
|
||||||
|
assert System.get_env("NO_WX") == "1"
|
||||||
|
|
||||||
|
System.delete_env("NO_WX")
|
||||||
|
assert Server.prepare_boot_env(:server, fn -> :ok end) == :server
|
||||||
|
assert System.get_env("NO_WX") == "1"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "desktop mode leaves wx enabled" do
|
||||||
|
assert Server.prepare_boot_env(:desktop, fn -> :ok end) == :desktop
|
||||||
|
assert System.get_env("NO_WX") == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tui mode silences terminal writers; desktop and server modes keep them" do
|
||||||
|
original = Application.get_env(:bumblebee, :progress_bar_enabled)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
if original == nil do
|
||||||
|
Application.delete_env(:bumblebee, :progress_bar_enabled)
|
||||||
|
else
|
||||||
|
Application.put_env(:bumblebee, :progress_bar_enabled, original)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
assert Server.prepare_boot_env(:tui, fn -> send(parent, :redirected) end) == :tui
|
||||||
|
assert Application.get_env(:bumblebee, :progress_bar_enabled) == false
|
||||||
|
assert_received :redirected
|
||||||
|
|
||||||
|
Server.prepare_boot_env(:desktop, fn -> send(parent, :redirected) end)
|
||||||
|
Server.prepare_boot_env(:server, fn -> send(parent, :redirected) end)
|
||||||
|
refute_received :redirected
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "tui logging redirect" do
|
||||||
|
setup do
|
||||||
|
dir = Path.join(System.tmp_dir!(), "bds-tui-log-#{System.unique_integer([:positive])}")
|
||||||
|
log_file = Path.join(dir, "bds.log")
|
||||||
|
Application.put_env(:bds, :tui_log_file, log_file)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
Application.delete_env(:bds, :tui_log_file)
|
||||||
|
File.rm_rf(dir)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, log_file: log_file}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tui_log_file defaults to the private app dir and honors the override", %{
|
||||||
|
log_file: log_file
|
||||||
|
} do
|
||||||
|
assert Server.tui_log_file() == log_file
|
||||||
|
|
||||||
|
Application.delete_env(:bds, :tui_log_file)
|
||||||
|
assert Server.tui_log_file() == Path.join([BDS.Projects.private_dir(), "logs", "bds.log"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirect_logging_to_file swaps the default handler to a rotating file handler", %{
|
||||||
|
log_file: log_file
|
||||||
|
} do
|
||||||
|
{:ok, original} = :logger.get_handler_config(:default)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
_ = :logger.remove_handler(:default)
|
||||||
|
|
||||||
|
:ok =
|
||||||
|
:logger.add_handler(
|
||||||
|
:default,
|
||||||
|
original.module,
|
||||||
|
Map.take(original, [:level, :filters, :filter_default, :formatter, :config])
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert Server.redirect_logging_to_file() == :ok
|
||||||
|
|
||||||
|
{:ok, handler} = :logger.get_handler_config(:default)
|
||||||
|
assert handler.module == :logger_std_h
|
||||||
|
assert handler.config.file == String.to_charlist(log_file)
|
||||||
|
assert handler.config.max_no_bytes > 0
|
||||||
|
# The swap must keep the Elixir log formatter, not fall back to OTP's.
|
||||||
|
assert handler.formatter == original.formatter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "ensure_ssh_dir!/1" do
|
describe "ensure_ssh_dir!/1" do
|
||||||
setup do
|
setup do
|
||||||
dir = Path.join(System.tmp_dir!(), "bds-ssh-test-#{System.unique_integer([:positive])}")
|
dir = Path.join(System.tmp_dir!(), "bds-ssh-test-#{System.unique_integer([:positive])}")
|
||||||
|
|||||||
@@ -38,14 +38,18 @@ defmodule BDS.TUITest do
|
|||||||
state
|
state
|
||||||
end
|
end
|
||||||
|
|
||||||
defp screen_text(state, width \\ 100, height \\ 32) do
|
defp screen_cells(state, width \\ 100, height \\ 32) do
|
||||||
session = CellSession.new(width, height)
|
session = CellSession.new(width, height)
|
||||||
frame = %ExRatatui.Frame{width: width, height: height}
|
frame = %ExRatatui.Frame{width: width, height: height}
|
||||||
:ok = CellSession.draw(session, BDS.TUI.render(state, frame))
|
:ok = CellSession.draw(session, BDS.TUI.render(state, frame))
|
||||||
snapshot = CellSession.take_cells(session)
|
snapshot = CellSession.take_cells(session)
|
||||||
:ok = CellSession.close(session)
|
:ok = CellSession.close(session)
|
||||||
|
|
||||||
snapshot.cells
|
snapshot.cells
|
||||||
|
end
|
||||||
|
|
||||||
|
defp screen_text(state, width \\ 100, height \\ 32) do
|
||||||
|
state
|
||||||
|
|> screen_cells(width, height)
|
||||||
|> Enum.group_by(& &1.row)
|
|> Enum.group_by(& &1.row)
|
||||||
|> Enum.sort_by(fn {row, _} -> row end)
|
|> Enum.sort_by(fn {row, _} -> row end)
|
||||||
|> Enum.map_join("\n", fn {_row, cells} ->
|
|> Enum.map_join("\n", fn {_row, cells} ->
|
||||||
@@ -53,7 +57,23 @@ defmodule BDS.TUITest do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp key(code, modifiers \\ []), do: %Key{code: code, kind: "press", modifiers: modifiers}
|
# The cell where `text` starts on screen (used to assert per-cell styles).
|
||||||
|
defp cell_at_text(cells, text) do
|
||||||
|
{_row, row_cells} =
|
||||||
|
cells
|
||||||
|
|> Enum.group_by(& &1.row)
|
||||||
|
|> Enum.find(fn {_row, row_cells} ->
|
||||||
|
row_cells |> Enum.sort_by(& &1.col) |> Enum.map_join("", & &1.symbol) |> String.contains?(text)
|
||||||
|
end)
|
||||||
|
|
||||||
|
sorted = Enum.sort_by(row_cells, & &1.col)
|
||||||
|
joined = Enum.map_join(sorted, & &1.symbol)
|
||||||
|
{byte_offset, _len} = :binary.match(joined, text)
|
||||||
|
char_offset = joined |> binary_part(0, byte_offset) |> String.length()
|
||||||
|
Enum.at(sorted, char_offset)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp key(code, modifiers), do: %Key{code: code, kind: "press", modifiers: modifiers}
|
||||||
|
|
||||||
defp press(state, code, modifiers \\ []) do
|
defp press(state, code, modifiers \\ []) do
|
||||||
{:noreply, state} = BDS.TUI.handle_event(key(code, modifiers), state)
|
{:noreply, state} = BDS.TUI.handle_event(key(code, modifiers), state)
|
||||||
@@ -68,13 +88,76 @@ defmodule BDS.TUITest do
|
|||||||
assert text =~ "Hello TUI"
|
assert text =~ "Hello TUI"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "navigates and opens a post in the editor", %{post: post} do
|
test "opening a post lands in the editor with the raw source", %{post: post} do
|
||||||
|
{:ok, _} =
|
||||||
|
BDS.Posts.update_post(post.id, %{content: "# Big Heading\n\nsome **bold** words"})
|
||||||
|
|
||||||
state = mount!() |> press("enter")
|
state = mount!() |> press("enter")
|
||||||
|
|
||||||
assert state.focus == :editor
|
assert state.focus == :editor
|
||||||
assert state.editor.post.id == post.id
|
assert state.editor.post.id == post.id
|
||||||
assert ExRatatui.textarea_get_value(state.editor.textarea) == "terminal body"
|
refute state.editor.preview?
|
||||||
assert screen_text(state) =~ "terminal body"
|
|
||||||
|
# The editor shows the raw markdown source.
|
||||||
|
assert screen_text(state) =~ "**bold**"
|
||||||
|
|
||||||
|
# ctrl+e flips to the rendered markdown preview: inline markers are
|
||||||
|
# consumed by the styling.
|
||||||
|
state = press(state, "e", ["ctrl"])
|
||||||
|
assert state.editor.preview?
|
||||||
|
|
||||||
|
text = screen_text(state)
|
||||||
|
assert text =~ "Big Heading"
|
||||||
|
assert text =~ "some bold words"
|
||||||
|
refute text =~ "**bold**"
|
||||||
|
|
||||||
|
# and back to the editor.
|
||||||
|
state = press(state, "e", ["ctrl"])
|
||||||
|
refute state.editor.preview?
|
||||||
|
assert screen_text(state) =~ "**bold**"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the editor word-wraps long lines", %{post: post} do
|
||||||
|
long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
|
||||||
|
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line})
|
||||||
|
|
||||||
|
state = mount!() |> press("enter")
|
||||||
|
refute state.editor.preview?
|
||||||
|
|
||||||
|
# The tail of a long line wraps onto visible rows without any
|
||||||
|
# horizontal scrolling.
|
||||||
|
assert screen_text(state, 80, 32) =~ "FINALWORD"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the cursor cell stays visible when the cursor sits below the viewport", %{post: post} do
|
||||||
|
content = Enum.map_join(1..30, "\n", &"line #{&1}")
|
||||||
|
{:ok, _} = BDS.Posts.update_post(post.id, %{content: content})
|
||||||
|
|
||||||
|
state = mount!() |> press("enter")
|
||||||
|
|
||||||
|
# The cursor opens at the end of the buffer — source row 29 of 30.
|
||||||
|
assert ExRatatui.textarea_cursor(state.editor.textarea) == {29, 7}
|
||||||
|
|
||||||
|
cells = screen_cells(state)
|
||||||
|
# The cursor cell is the single space carrying the cursor background
|
||||||
|
# inside the editor area (the sidebar selection also uses a cyan bg).
|
||||||
|
assert Enum.any?(cells, &(&1.bg == :cyan and &1.symbol == " " and &1.col >= 34))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the cursor line has a distinct background from the other lines", %{post: post} do
|
||||||
|
{:ok, _} = BDS.Posts.update_post(post.id, %{content: "first\nsecond\nthird"})
|
||||||
|
|
||||||
|
state = mount!() |> press("enter")
|
||||||
|
|
||||||
|
# Move the cursor from the end of the buffer onto the first line.
|
||||||
|
state = state |> press("up") |> press("up")
|
||||||
|
assert ExRatatui.textarea_cursor(state.editor.textarea) == {0, 5}
|
||||||
|
|
||||||
|
cells = screen_cells(state)
|
||||||
|
# A fixed dark grey — readable against the light syntax colours in
|
||||||
|
# any terminal palette (the named :dark_gray is palette-dependent).
|
||||||
|
assert cell_at_text(cells, "first").bg == {:rgb, 52, 61, 70}
|
||||||
|
refute cell_at_text(cells, "second").bg == {:rgb, 52, 61, 70}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "ctrl+s persists textarea edits", %{post: post} do
|
test "ctrl+s persists textarea edits", %{post: post} do
|
||||||
@@ -95,13 +178,16 @@ defmodule BDS.TUITest do
|
|||||||
assert state.editor.post.status == :published
|
assert state.editor.post.status == :published
|
||||||
end
|
end
|
||||||
|
|
||||||
test "n creates a new draft post", %{project: project} do
|
test "n creates a new draft post and starts in the editor, not the preview", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
count_before = post_count(project.id)
|
count_before = post_count(project.id)
|
||||||
state = mount!() |> press("n")
|
state = mount!() |> press("n")
|
||||||
|
|
||||||
assert post_count(project.id) == count_before + 1
|
assert post_count(project.id) == count_before + 1
|
||||||
assert state.focus == :editor
|
assert state.focus == :editor
|
||||||
assert state.editor.post.status == :draft
|
assert state.editor.post.status == :draft
|
||||||
|
refute state.editor.preview?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "esc returns focus to the sidebar" do
|
test "esc returns focus to the sidebar" do
|
||||||
@@ -109,23 +195,18 @@ defmodule BDS.TUITest do
|
|||||||
assert state.focus == :sidebar
|
assert state.focus == :sidebar
|
||||||
end
|
end
|
||||||
|
|
||||||
test "ctrl+e toggles a word-wrapped preview of the draft", %{post: post} do
|
test "the preview word-wraps long lines", %{post: post} do
|
||||||
long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
|
long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
|
||||||
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line})
|
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line})
|
||||||
|
|
||||||
state = mount!() |> press("enter") |> press("e", ["ctrl"])
|
state = mount!() |> press("enter") |> press("e", ["ctrl"])
|
||||||
assert state.editor.preview?
|
assert state.editor.preview?
|
||||||
|
|
||||||
# The textarea cannot soft-wrap, so the tail of a long line is off-screen
|
|
||||||
# there; the preview renders through the Markdown widget, which wraps.
|
|
||||||
assert screen_text(state, 80, 32) =~ "FINALWORD"
|
assert screen_text(state, 80, 32) =~ "FINALWORD"
|
||||||
|
|
||||||
state = press(state, "e", ["ctrl"])
|
|
||||||
refute state.editor.preview?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "keys in preview mode do not edit the content" do
|
test "keys in preview mode do not edit the content" do
|
||||||
state = mount!() |> press("enter") |> press("e", ["ctrl"])
|
state = mount!() |> press("enter") |> press("e", ["ctrl"])
|
||||||
|
assert state.editor.preview?
|
||||||
before = ExRatatui.textarea_get_value(state.editor.textarea)
|
before = ExRatatui.textarea_get_value(state.editor.textarea)
|
||||||
|
|
||||||
state = press(state, "x")
|
state = press(state, "x")
|
||||||
@@ -133,11 +214,12 @@ defmodule BDS.TUITest do
|
|||||||
refute state.editor.dirty
|
refute state.editor.dirty
|
||||||
end
|
end
|
||||||
|
|
||||||
test "esc in preview returns to editing, not to the sidebar" do
|
test "esc returns to the sidebar from both preview and editor" do
|
||||||
state = mount!() |> press("enter") |> press("e", ["ctrl"]) |> press("esc")
|
state = mount!() |> press("enter") |> press("esc")
|
||||||
|
assert state.focus == :sidebar
|
||||||
|
|
||||||
refute state.editor.preview?
|
state = state |> press("enter") |> press("e", ["ctrl"]) |> press("esc")
|
||||||
assert state.focus == :editor
|
assert state.focus == :sidebar
|
||||||
end
|
end
|
||||||
|
|
||||||
test "entity_changed refreshes the sidebar", %{project: project} do
|
test "entity_changed refreshes the sidebar", %{project: project} do
|
||||||
@@ -224,7 +306,7 @@ defmodule BDS.TUITest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "up/down move the selection" do
|
test "up/down move the selection" do
|
||||||
state = mount_with_executor!() |> press(":") |> press("down") |> press("enter")
|
_state = mount_with_executor!() |> press(":") |> press("down") |> press("enter")
|
||||||
|
|
||||||
assert_receive {:executed, action, %{}}
|
assert_receive {:executed, action, %{}}
|
||||||
assert is_binary(action)
|
assert is_binary(action)
|
||||||
@@ -416,6 +498,671 @@ defmodule BDS.TUITest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "projects overlay" do
|
||||||
|
defp type(state, text) do
|
||||||
|
text |> String.graphemes() |> Enum.reduce(state, &press(&2, &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp move_to(state, index) do
|
||||||
|
moves = index - state.projects.selected
|
||||||
|
code = if moves >= 0, do: "down", else: "up"
|
||||||
|
Enum.reduce(List.duplicate(code, abs(moves)), state, &press(&2, &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "'p' opens the overlay listing all projects with the active one selected", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
state = mount!() |> press("p")
|
||||||
|
|
||||||
|
assert state.projects.mode == :list
|
||||||
|
active = Enum.at(state.projects.projects, state.projects.selected)
|
||||||
|
assert active.id == project.id
|
||||||
|
|
||||||
|
text = screen_text(state, 140, 40)
|
||||||
|
assert text =~ project.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "esc closes the overlay" do
|
||||||
|
state = mount!() |> press("p") |> press("esc")
|
||||||
|
assert state.projects == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "enter switches the active project and reloads the sidebar", %{project: project} do
|
||||||
|
other_dir = Path.join(System.tmp_dir!(), "bds-tui-other-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(other_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(other_dir) end)
|
||||||
|
|
||||||
|
{:ok, other} = BDS.Projects.create_project(%{name: "Other Blog", data_path: other_dir})
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
BDS.Posts.create_post(%{project_id: other.id, title: "Other Post", content: "x"})
|
||||||
|
|
||||||
|
state = mount!() |> press("p")
|
||||||
|
index = Enum.find_index(state.projects.projects, &(&1.id == other.id))
|
||||||
|
state = state |> move_to(index) |> press("enter")
|
||||||
|
|
||||||
|
assert state.projects == nil
|
||||||
|
assert state.project_id == other.id
|
||||||
|
assert BDS.Projects.get_active_project().id == other.id
|
||||||
|
assert screen_text(state) =~ "Other Post"
|
||||||
|
|
||||||
|
# switching back must not churn: selecting the active project is a no-op
|
||||||
|
{:ok, _} = BDS.Projects.set_active_project(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "'o' plus a valid path opens the folder as a project and queues a rebuild" do
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
import_dir =
|
||||||
|
Path.join(System.tmp_dir!(), "bds-tui-import-#{System.unique_integer([:positive])}")
|
||||||
|
|
||||||
|
File.mkdir_p!(import_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(import_dir) end)
|
||||||
|
|
||||||
|
state =
|
||||||
|
mount!(
|
||||||
|
command_executor: fn action, params ->
|
||||||
|
send(parent, {:executed, action, params})
|
||||||
|
{:ok, %{kind: "task_queued", title: "Rebuild", message: "queued"}}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|> press("p")
|
||||||
|
|> press("o")
|
||||||
|
|
||||||
|
assert state.projects.mode == :open
|
||||||
|
|
||||||
|
state = state |> type(import_dir) |> press("enter")
|
||||||
|
|
||||||
|
assert state.projects == nil
|
||||||
|
imported = Enum.find(BDS.Projects.list_projects(), &(&1.data_path == import_dir))
|
||||||
|
assert imported != nil
|
||||||
|
assert BDS.Projects.get_active_project().id == imported.id
|
||||||
|
assert state.project_id == imported.id
|
||||||
|
|
||||||
|
assert_receive {:executed, "rebuild_database", %{}}
|
||||||
|
assert state.task_polling
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an invalid path keeps the prompt open and reports the error" do
|
||||||
|
parent = self()
|
||||||
|
count_before = length(BDS.Projects.list_projects())
|
||||||
|
|
||||||
|
state =
|
||||||
|
mount!(command_executor: fn action, params -> send(parent, {:executed, action, params}) end)
|
||||||
|
|> press("p")
|
||||||
|
|> press("o")
|
||||||
|
|> type("/nonexistent-bds-folder-xyz")
|
||||||
|
|> press("enter")
|
||||||
|
|
||||||
|
assert state.projects.mode == :open
|
||||||
|
assert state.status != nil
|
||||||
|
assert length(BDS.Projects.list_projects()) == count_before
|
||||||
|
refute_receive {:executed, _action, _params}, 50
|
||||||
|
end
|
||||||
|
|
||||||
|
test "esc in the path prompt returns to the project list" do
|
||||||
|
state = mount!() |> press("p") |> press("o") |> press("esc")
|
||||||
|
assert state.projects.mode == :list
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "sidebar search" do
|
||||||
|
setup %{project: project} do
|
||||||
|
{:ok, tagged} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Tagged Post",
|
||||||
|
content: "x",
|
||||||
|
tags: ["elixir"],
|
||||||
|
categories: ["news"]
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, tagged: tagged}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp item_titles(state) do
|
||||||
|
for {:item, item} <- state.items, do: to_string(item[:title] || item[:name])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp search(state, query) do
|
||||||
|
state |> press("/") |> type(query)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "'/' opens the prompt and typing filters the list live" do
|
||||||
|
state = mount!() |> search("tagged")
|
||||||
|
|
||||||
|
assert state.search != nil
|
||||||
|
assert item_titles(state) == ["Tagged Post"]
|
||||||
|
assert screen_text(state) =~ "/tagged"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tag: filters by tag" do
|
||||||
|
state = mount!() |> search("tag:elixir")
|
||||||
|
assert item_titles(state) == ["Tagged Post"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "category: filters by category" do
|
||||||
|
state = mount!() |> search("category:news")
|
||||||
|
assert item_titles(state) == ["Tagged Post"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an ISO date shows the posts from that date", %{post: post} do
|
||||||
|
on_ms =
|
||||||
|
DateTime.new!(~D[2026-07-04], ~T[10:00:00], "Etc/UTC") |> DateTime.to_unix(:millisecond)
|
||||||
|
|
||||||
|
BDS.Repo.update_all(
|
||||||
|
from(p in BDS.Posts.Post, where: p.id == ^post.id),
|
||||||
|
set: [updated_at: on_ms]
|
||||||
|
)
|
||||||
|
|
||||||
|
state = mount!() |> search("2026-07-04")
|
||||||
|
assert item_titles(state) == ["Hello TUI"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tokens combine: tag plus text must both match" do
|
||||||
|
state = mount!() |> search("tag:elixir hello")
|
||||||
|
assert item_titles(state) == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "enter keeps the filter and shows it in the sidebar title" do
|
||||||
|
state = mount!() |> search("tag:elixir") |> press("enter")
|
||||||
|
|
||||||
|
assert state.search == nil
|
||||||
|
assert item_titles(state) == ["Tagged Post"]
|
||||||
|
assert screen_text(state) =~ "/tag:elixir"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "esc clears the filter and restores the full list" do
|
||||||
|
state = mount!() |> search("tag:elixir") |> press("esc")
|
||||||
|
|
||||||
|
assert state.search == nil
|
||||||
|
assert "Hello TUI" in item_titles(state)
|
||||||
|
assert "Tagged Post" in item_titles(state)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the filter applies per view and works for media", %{project: project} do
|
||||||
|
media_file = Path.join(System.tmp_dir!(), "bds-tui-media-#{System.unique_integer([:positive])}.txt")
|
||||||
|
File.write!(media_file, "media body")
|
||||||
|
on_exit(fn -> File.rm(media_file) end)
|
||||||
|
|
||||||
|
{:ok, _media} =
|
||||||
|
BDS.Media.import_media(%{
|
||||||
|
project_id: project.id,
|
||||||
|
source_path: media_file,
|
||||||
|
title: "Findable Media"
|
||||||
|
})
|
||||||
|
|
||||||
|
state = mount!() |> press("2") |> search("findable") |> press("enter")
|
||||||
|
assert item_titles(state) == ["Findable Media"]
|
||||||
|
|
||||||
|
# posts view keeps its own (empty) filter
|
||||||
|
state = press(state, "1")
|
||||||
|
assert "Hello TUI" in item_titles(state)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "path completion" do
|
||||||
|
setup do
|
||||||
|
base = Path.join(System.tmp_dir!(), "bds-tui-tab-#{System.unique_integer([:positive])}")
|
||||||
|
|
||||||
|
for dir <- ["blog-one", "blog-two", "alpha-unique", ".secret"] do
|
||||||
|
File.mkdir_p!(Path.join(base, dir))
|
||||||
|
end
|
||||||
|
|
||||||
|
File.write!(Path.join(base, "blog-notes.txt"), "not a folder")
|
||||||
|
on_exit(fn -> File.rm_rf(base) end)
|
||||||
|
{:ok, base: base}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prompt!(path) do
|
||||||
|
mount!() |> press("p") |> press("o") |> type(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tab completes a unique directory prefix with a trailing slash", %{base: base} do
|
||||||
|
state = prompt!(Path.join(base, "alph")) |> press("tab")
|
||||||
|
|
||||||
|
assert state.projects.input == Path.join(base, "alpha-unique") <> "/"
|
||||||
|
assert state.projects.matches == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tab completes ambiguous prefixes to the longest common prefix and lists candidates",
|
||||||
|
%{base: base} do
|
||||||
|
state = prompt!(Path.join(base, "bl")) |> press("tab")
|
||||||
|
|
||||||
|
# Files are not offered — this is a folder picker.
|
||||||
|
assert state.projects.input == Path.join(base, "blog-")
|
||||||
|
assert state.projects.matches == ["blog-one", "blog-two"]
|
||||||
|
assert screen_text(state, 140, 40) =~ "blog-one"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "candidates are cleared as soon as typing continues", %{base: base} do
|
||||||
|
state = prompt!(Path.join(base, "bl")) |> press("tab") |> press("o")
|
||||||
|
assert state.projects.matches == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "hidden folders are not offered unless the prefix starts with a dot", %{base: base} do
|
||||||
|
state = prompt!(base <> "/") |> press("tab")
|
||||||
|
refute ".secret" in state.projects.matches
|
||||||
|
|
||||||
|
state = prompt!(Path.join(base, ".se")) |> press("tab")
|
||||||
|
assert state.projects.input == Path.join(base, ".secret") <> "/"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tab with no matches leaves the input unchanged", %{base: base} do
|
||||||
|
input = Path.join(base, "zzz")
|
||||||
|
state = prompt!(input) |> press("tab")
|
||||||
|
|
||||||
|
assert state.projects.input == input
|
||||||
|
assert state.projects.matches == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a leading ~ expands to the home directory" do
|
||||||
|
state = prompt!("~/") |> press("tab")
|
||||||
|
assert String.starts_with?(state.projects.input, System.user_home!() <> "/")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "git view" do
|
||||||
|
defp git!(dir, args) do
|
||||||
|
{output, 0} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
|
||||||
|
output
|
||||||
|
end
|
||||||
|
|
||||||
|
defp init_repo!(dir) do
|
||||||
|
git!(dir, ["init"])
|
||||||
|
git!(dir, ["config", "user.email", "tui@test"])
|
||||||
|
git!(dir, ["config", "user.name", "TUI Test"])
|
||||||
|
File.write!(Path.join(dir, "tracked.md"), "original\n")
|
||||||
|
git!(dir, ["add", "-A"])
|
||||||
|
git!(dir, ["commit", "-m", "initial"])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp modify!(dir, file, content), do: File.write!(Path.join(dir, file), content)
|
||||||
|
|
||||||
|
test "'7' opens the git view with changed files and a scrollable diff", %{project: project} do
|
||||||
|
dir = BDS.Projects.project_data_dir(project)
|
||||||
|
init_repo!(dir)
|
||||||
|
modify!(dir, "tracked.md", "changed\n")
|
||||||
|
File.write!(Path.join(dir, "new-file.md"), "brand new\n")
|
||||||
|
|
||||||
|
state = mount!() |> press("7")
|
||||||
|
|
||||||
|
assert state.view == "git"
|
||||||
|
labels = for {:item, item} <- state.items, do: item.label
|
||||||
|
assert "M tracked.md" in labels
|
||||||
|
assert "U new-file.md" in labels
|
||||||
|
|
||||||
|
assert state.git != nil
|
||||||
|
assert Enum.any?(state.git.lines, &String.starts_with?(&1, "diff --git"))
|
||||||
|
assert screen_text(state, 140, 40) =~ "diff --git"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pgdn/pgup scroll the diff", %{project: project} do
|
||||||
|
dir = BDS.Projects.project_data_dir(project)
|
||||||
|
init_repo!(dir)
|
||||||
|
modify!(dir, "tracked.md", Enum.map_join(1..60, "\n", &"line #{&1}") <> "\n")
|
||||||
|
|
||||||
|
state = mount!() |> press("7") |> press("pagedown")
|
||||||
|
assert state.git.scroll > 0
|
||||||
|
|
||||||
|
state = press(state, "pageup")
|
||||||
|
assert state.git.scroll == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "enter on a changed file jumps the diff to that file", %{project: project} do
|
||||||
|
dir = BDS.Projects.project_data_dir(project)
|
||||||
|
init_repo!(dir)
|
||||||
|
File.write!(Path.join(dir, "zz-later.md"), "z\n")
|
||||||
|
git!(dir, ["add", "-A"])
|
||||||
|
git!(dir, ["commit", "-m", "second file"])
|
||||||
|
modify!(dir, "tracked.md", "changed\n")
|
||||||
|
modify!(dir, "zz-later.md", "changed too\n")
|
||||||
|
|
||||||
|
state = mount!() |> press("7")
|
||||||
|
index = Enum.find_index(state.items, fn {:item, item} -> item.id == "zz-later.md" end)
|
||||||
|
state = %{state | selected: index} |> press("enter")
|
||||||
|
|
||||||
|
line = Enum.at(state.git.lines, state.git.scroll)
|
||||||
|
assert line =~ "diff --git"
|
||||||
|
assert line =~ "zz-later.md"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "'c' opens the commit prompt and enter commits everything", %{project: project} do
|
||||||
|
dir = BDS.Projects.project_data_dir(project)
|
||||||
|
init_repo!(dir)
|
||||||
|
modify!(dir, "tracked.md", "changed\n")
|
||||||
|
|
||||||
|
state = mount!() |> press("7") |> press("c")
|
||||||
|
assert state.git_commit != nil
|
||||||
|
assert screen_text(state) =~ "Commit message:"
|
||||||
|
|
||||||
|
state = state |> type("content sync")
|
||||||
|
assert screen_text(state) =~ "content sync"
|
||||||
|
|
||||||
|
state = press(state, "enter")
|
||||||
|
|
||||||
|
assert state.git_commit == nil
|
||||||
|
assert {:ok, %{files: []}} = BDS.Git.status(project.id)
|
||||||
|
assert state.items == []
|
||||||
|
assert git!(dir, ["log", "-1", "--format=%s"]) =~ "content sync"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "an empty commit message is rejected", %{project: project} do
|
||||||
|
dir = BDS.Projects.project_data_dir(project)
|
||||||
|
init_repo!(dir)
|
||||||
|
modify!(dir, "tracked.md", "changed\n")
|
||||||
|
|
||||||
|
state = mount!() |> press("7") |> press("c") |> press("enter")
|
||||||
|
|
||||||
|
assert state.git_commit == nil
|
||||||
|
assert state.status != nil
|
||||||
|
assert {:ok, %{files: [_change]}} = BDS.Git.status(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "'u' pulls asynchronously and reports the result", %{project: project} do
|
||||||
|
dir = BDS.Projects.project_data_dir(project)
|
||||||
|
init_repo!(dir)
|
||||||
|
|
||||||
|
state = mount!() |> press("7") |> press("u")
|
||||||
|
assert state.busy
|
||||||
|
|
||||||
|
# No remote is configured, so the streamed result is an error toast.
|
||||||
|
assert_receive {:git_result, :pull, {:error, _reason}}, 5_000
|
||||||
|
{:noreply, state} = BDS.TUI.handle_info({:git_result, :pull, {:error, {:git_failed, "no remote"}}}, state)
|
||||||
|
refute state.busy
|
||||||
|
assert state.status =~ "no remote"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "'s' pushes asynchronously", %{project: project} do
|
||||||
|
dir = BDS.Projects.project_data_dir(project)
|
||||||
|
init_repo!(dir)
|
||||||
|
|
||||||
|
state = mount!() |> press("7") |> press("s")
|
||||||
|
assert state.busy
|
||||||
|
assert_receive {:git_result, :push, {:error, _reason}}, 5_000
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the sidebar shows the commit history in its lower half", %{project: project} do
|
||||||
|
dir = BDS.Projects.project_data_dir(project)
|
||||||
|
init_repo!(dir)
|
||||||
|
|
||||||
|
state = mount!() |> press("7")
|
||||||
|
|
||||||
|
assert [entry | _] = state.sidebar.history_entries
|
||||||
|
assert entry.subject == "initial"
|
||||||
|
|
||||||
|
text = screen_text(state, 140, 40)
|
||||||
|
assert text =~ "History"
|
||||||
|
# No remote exists, so the commit is local-only and marked for push.
|
||||||
|
assert text =~ "↑ #{entry.short_hash} initial"
|
||||||
|
|
||||||
|
# A commit from the panel shows up in the history immediately.
|
||||||
|
modify!(dir, "tracked.md", "changed\n")
|
||||||
|
state = state |> press("r") |> press("c") |> type("sync commit") |> press("enter")
|
||||||
|
|
||||||
|
assert screen_text(state, 140, 40) =~ "sync commit"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "git keys in a non-repo project only report", %{project: project} do
|
||||||
|
state = mount!() |> press("7")
|
||||||
|
|
||||||
|
assert state.view == "git"
|
||||||
|
assert state.git == nil
|
||||||
|
assert state.items == []
|
||||||
|
assert screen_text(state, 140, 40) =~ "Not a git repository."
|
||||||
|
|
||||||
|
state = press(state, "c")
|
||||||
|
assert state.git_commit == nil
|
||||||
|
assert state.status != nil
|
||||||
|
|
||||||
|
state = press(state, "u")
|
||||||
|
refute state.busy
|
||||||
|
refute_receive {:git_result, _op, _result}, 100
|
||||||
|
_ = project
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "settings panel (issue #29)" do
|
||||||
|
defp open_section(state, title) do
|
||||||
|
index =
|
||||||
|
Enum.find_index(state.items, fn
|
||||||
|
{:item, item} -> item.title == title
|
||||||
|
_other -> false
|
||||||
|
end)
|
||||||
|
|
||||||
|
moves = index - state.selected
|
||||||
|
direction = if moves < 0, do: "up", else: "down"
|
||||||
|
|
||||||
|
1..abs(moves)//1
|
||||||
|
|> Enum.reduce(state, fn _step, acc -> press(acc, direction) end)
|
||||||
|
|> press("enter")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp select_field(state, key) do
|
||||||
|
index = Enum.find_index(state.settings_form.fields, &(&1.key == key))
|
||||||
|
moves = index - state.settings_form.selected
|
||||||
|
direction = if moves < 0, do: "up", else: "down"
|
||||||
|
Enum.reduce(1..abs(moves)//1, state, fn _step, acc -> press(acc, direction) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "6 opens the settings view and lists the sections" do
|
||||||
|
state = mount!() |> press("6")
|
||||||
|
|
||||||
|
assert state.view == "settings"
|
||||||
|
text = screen_text(state)
|
||||||
|
assert text =~ "Project"
|
||||||
|
assert text =~ "Publishing"
|
||||||
|
assert text =~ "Style"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "enter opens a section form; editing a text field persists on ctrl+s", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
state = mount!() |> press("6") |> open_section("Project")
|
||||||
|
|
||||||
|
assert state.settings_form != nil
|
||||||
|
assert state.settings_form.section == "project"
|
||||||
|
assert screen_text(state, 140, 40) =~ "Posts per Page"
|
||||||
|
|
||||||
|
state = select_field(state, "name") |> press("enter")
|
||||||
|
assert state.settings_form.input != nil
|
||||||
|
|
||||||
|
# The prompt is seeded with the current value; replace it.
|
||||||
|
state =
|
||||||
|
Enum.reduce(1..3, state, fn _n, acc -> press(acc, "backspace") end)
|
||||||
|
|> type("TUI Prefs")
|
||||||
|
|> press("enter")
|
||||||
|
|
||||||
|
assert state.settings_form.input == nil
|
||||||
|
assert state.settings_form.dirty
|
||||||
|
|
||||||
|
state = press(state, "s", ["ctrl"])
|
||||||
|
refute state.settings_form.dirty
|
||||||
|
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.name == "TUI Prefs"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "enter toggles booleans and cycles enums", %{project: project} do
|
||||||
|
state = mount!() |> press("6") |> open_section("Publishing")
|
||||||
|
|
||||||
|
state = select_field(state, "ssh_mode")
|
||||||
|
assert current_field(state).value == "scp"
|
||||||
|
|
||||||
|
state = press(state, "enter")
|
||||||
|
assert current_field(state).value == "rsync"
|
||||||
|
|
||||||
|
state = state |> select_field("ssh_host") |> press("enter") |> type("example.org") |> press("enter")
|
||||||
|
state = press(state, "s", ["ctrl"])
|
||||||
|
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.publishing_preferences["ssh_mode"] == "rsync"
|
||||||
|
assert metadata.publishing_preferences["ssh_host"] == "example.org"
|
||||||
|
|
||||||
|
# Booleans toggle in place.
|
||||||
|
state = state |> press("esc") |> open_section("Technology")
|
||||||
|
state = select_field(state, "semantic_similarity_enabled")
|
||||||
|
refute current_field(state).value
|
||||||
|
state = press(state, "enter")
|
||||||
|
assert current_field(state).value
|
||||||
|
end
|
||||||
|
|
||||||
|
test "esc in a prompt cancels only the prompt; esc again closes the form" do
|
||||||
|
state = mount!() |> press("6") |> open_section("Publishing")
|
||||||
|
|
||||||
|
state = select_field(state, "ssh_host") |> press("enter")
|
||||||
|
assert state.settings_form.input != nil
|
||||||
|
|
||||||
|
state = press(state, "esc")
|
||||||
|
assert state.settings_form != nil
|
||||||
|
assert state.settings_form.input == nil
|
||||||
|
|
||||||
|
state = press(state, "esc")
|
||||||
|
assert state.settings_form == nil
|
||||||
|
assert state.view == "settings"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp current_field(state),
|
||||||
|
do: Enum.at(state.settings_form.fields, state.settings_form.selected)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "tags panel (issue #34)" do
|
||||||
|
defp open_tags_section(state, section) do
|
||||||
|
state = press(state, "5")
|
||||||
|
|
||||||
|
index =
|
||||||
|
Enum.find_index(state.items, fn
|
||||||
|
{:item, %{id: id}} -> id == "tags-" <> section
|
||||||
|
_other -> false
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert index != nil
|
||||||
|
press(%{state | selected: index}, "enter")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp type_text(state, text) do
|
||||||
|
text
|
||||||
|
|> String.graphemes()
|
||||||
|
|> Enum.reduce(state, fn char, acc -> press(acc, char) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tag_names(project_id) do
|
||||||
|
project_id |> BDS.Tags.list_tags() |> Enum.map(& &1.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the sidebar entries open the panel sections in the main area", %{project: project} do
|
||||||
|
{:ok, _tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "elixir"})
|
||||||
|
|
||||||
|
state = open_tags_section(mount!(), "cloud")
|
||||||
|
assert state.tags_panel.section == "cloud"
|
||||||
|
assert screen_text(state) =~ "elixir (0)"
|
||||||
|
|
||||||
|
state = press(state, "esc")
|
||||||
|
assert state.tags_panel == nil
|
||||||
|
|
||||||
|
state = open_tags_section(state, "manage")
|
||||||
|
assert state.tags_panel.section == "manage"
|
||||||
|
|
||||||
|
state = open_tags_section(press(state, "esc"), "merge")
|
||||||
|
assert state.tags_panel.section == "merge"
|
||||||
|
assert screen_text(state) =~ "[ ] elixir (0)"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "manage creates a tag through the n prompt", %{project: project} do
|
||||||
|
state = open_tags_section(mount!(), "manage")
|
||||||
|
|
||||||
|
state = press(state, "n")
|
||||||
|
assert state.tags_panel.input.kind == :new
|
||||||
|
|
||||||
|
state = state |> type_text("neu") |> press("enter")
|
||||||
|
|
||||||
|
assert "neu" in tag_names(project.id)
|
||||||
|
assert state.tags_panel.input == nil
|
||||||
|
assert state.status =~ "neu"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "manage renames the selected tag through the enter prompt", %{project: project} do
|
||||||
|
{:ok, _tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "alpha"})
|
||||||
|
|
||||||
|
state = open_tags_section(mount!(), "manage")
|
||||||
|
state = press(state, "enter")
|
||||||
|
assert state.tags_panel.input.kind == :rename
|
||||||
|
assert state.tags_panel.input.value == "alpha"
|
||||||
|
|
||||||
|
state =
|
||||||
|
Enum.reduce(1..5, state, fn _n, acc -> press(acc, "backspace") end)
|
||||||
|
|
||||||
|
_state = state |> type_text("beta") |> press("enter")
|
||||||
|
|
||||||
|
assert tag_names(project.id) == ["beta"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "manage cycles the tag colour with c", %{project: project} do
|
||||||
|
{:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "colored"})
|
||||||
|
|
||||||
|
state = open_tags_section(mount!(), "manage")
|
||||||
|
_state = press(state, "c")
|
||||||
|
|
||||||
|
updated = BDS.Repo.get!(BDS.Tags.Tag, tag.id)
|
||||||
|
assert updated.color == List.first(BDS.UI.TagsPanel.colour_presets())
|
||||||
|
end
|
||||||
|
|
||||||
|
test "manage deletes only after y confirmation", %{project: project} do
|
||||||
|
{:ok, _tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "doomed"})
|
||||||
|
|
||||||
|
state = open_tags_section(mount!(), "manage")
|
||||||
|
|
||||||
|
# Any other key cancels the pending delete.
|
||||||
|
state = state |> press("d") |> press("x")
|
||||||
|
assert state.tags_panel.confirm_delete == nil
|
||||||
|
assert "doomed" in tag_names(project.id)
|
||||||
|
|
||||||
|
state = state |> press("d")
|
||||||
|
assert state.tags_panel.confirm_delete == "doomed"
|
||||||
|
|
||||||
|
state = press(state, "y")
|
||||||
|
assert tag_names(project.id) == []
|
||||||
|
assert state.status =~ "doomed"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "merge marks tags with space and merges them into the selection", %{project: project} do
|
||||||
|
{:ok, _one} = BDS.Tags.create_tag(%{project_id: project.id, name: "one"})
|
||||||
|
{:ok, _two} = BDS.Tags.create_tag(%{project_id: project.id, name: "two"})
|
||||||
|
|
||||||
|
{:ok, tagged_post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Tagged",
|
||||||
|
content: "body",
|
||||||
|
tags: ["one"]
|
||||||
|
})
|
||||||
|
|
||||||
|
state = open_tags_section(mount!(), "merge")
|
||||||
|
|
||||||
|
# Mark "one", move to "two", merge — "one" is folded into "two".
|
||||||
|
_state = state |> press(" ") |> press("down") |> press("m")
|
||||||
|
|
||||||
|
assert tag_names(project.id) == ["two"]
|
||||||
|
assert BDS.Posts.get_post!(tagged_post.id).tags == ["two"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "manage syncs tags from post tags with s", %{project: project} do
|
||||||
|
{:ok, _post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "With Tags",
|
||||||
|
content: "body",
|
||||||
|
tags: ["from-post"]
|
||||||
|
})
|
||||||
|
|
||||||
|
state = open_tags_section(mount!(), "manage")
|
||||||
|
state = press(state, "s")
|
||||||
|
|
||||||
|
assert "from-post" in tag_names(project.id)
|
||||||
|
assert Enum.any?(state.tags_panel.tags, &(&1.name == "from-post"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "local tui mode stops the VM when the app exits" do
|
test "local tui mode stops the VM when the app exits" do
|
||||||
parent = self()
|
parent = self()
|
||||||
state = mount!(stop_vm_on_exit: true, stop_fun: fn -> send(parent, :vm_stopped) end)
|
state = mount!(stop_vm_on_exit: true, stop_fun: fn -> send(parent, :vm_stopped) end)
|
||||||
|
|||||||
199
test/bds/ui/code_editor_test.exs
Normal file
199
test/bds/ui/code_editor_test.exs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
defmodule BDS.UI.CodeEditorTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias BDS.UI.CodeEditor
|
||||||
|
alias BDS.UI.CodeEditor.Highlight
|
||||||
|
alias ExRatatui.Layout.Rect
|
||||||
|
alias ExRatatui.Style
|
||||||
|
alias ExRatatui.Text.Line
|
||||||
|
alias ExRatatui.Widgets.Paragraph
|
||||||
|
alias ExRatatui.Widgets.Textarea
|
||||||
|
|
||||||
|
@theme :base16_ocean_dark
|
||||||
|
@rect %Rect{x: 0, y: 0, width: 40, height: 10}
|
||||||
|
|
||||||
|
defp find_span(line, text) do
|
||||||
|
Enum.find(line.spans, fn span -> String.contains?(span.content, text) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp editor_widget(content, opts \\ []) do
|
||||||
|
textarea = ExRatatui.textarea_new()
|
||||||
|
:ok = ExRatatui.textarea_insert_str(textarea, content)
|
||||||
|
|
||||||
|
widget = %CodeEditor{
|
||||||
|
textarea: textarea,
|
||||||
|
language: Keyword.get(opts, :language, :markdown_macros),
|
||||||
|
cursor_style: Keyword.get(opts, :cursor_style, %Style{bg: :cyan}),
|
||||||
|
line_highlight_style: Keyword.get(opts, :line_highlight_style, %Style{}),
|
||||||
|
block: Keyword.get(opts, :block),
|
||||||
|
scroll: Keyword.get(opts, :scroll, {0, 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
{widget, textarea}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render(widget, rect \\ @rect) do
|
||||||
|
[{paragraph, ^rect}] = ExRatatui.Widget.render(widget, rect)
|
||||||
|
paragraph
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Highlight.highlight/3 — :lua" do
|
||||||
|
test "colours keywords with the theme keyword colour, distinct from identifiers" do
|
||||||
|
[first, _middle, last] =
|
||||||
|
Highlight.highlight("local function foo()\n return 1\nend", :lua, @theme)
|
||||||
|
|
||||||
|
keyword = find_span(first, "function")
|
||||||
|
assert keyword.style.fg != nil
|
||||||
|
assert find_span(last, "end").style.fg == keyword.style.fg
|
||||||
|
refute find_span(first, "foo").style.fg == keyword.style.fg
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Highlight.highlight/3 — :markdown_macros" do
|
||||||
|
test "recolours [[macro]] syntax with the GUI accent colours" do
|
||||||
|
[line] = Highlight.highlight(~s([[gallery names="x"]]), :markdown_macros, @theme)
|
||||||
|
|
||||||
|
opener = find_span(line, "[[gallery")
|
||||||
|
assert opener.style.fg == {:rgb, 197, 134, 192}
|
||||||
|
assert :bold in opener.style.modifiers
|
||||||
|
|
||||||
|
closer = find_span(line, "]]")
|
||||||
|
assert closer.style.fg == {:rgb, 197, 134, 192}
|
||||||
|
assert :bold in closer.style.modifiers
|
||||||
|
|
||||||
|
assert find_span(line, "names").style.fg == {:rgb, 156, 220, 254}
|
||||||
|
assert find_span(line, ~s("x")).style.fg == {:rgb, 206, 145, 120}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "keeps the base markdown styling outside macros" do
|
||||||
|
content = ~s(before [[gallery names="x"]] after)
|
||||||
|
|
||||||
|
[overlayed] = Highlight.highlight(content, :markdown_macros, @theme)
|
||||||
|
[base] = ExRatatui.CodeBlock.highlight(content, "markdown", @theme)
|
||||||
|
|
||||||
|
base_style = find_span(base, "before").style
|
||||||
|
assert find_span(overlayed, "before").style == base_style
|
||||||
|
assert find_span(overlayed, "after").style == base_style
|
||||||
|
end
|
||||||
|
|
||||||
|
test "highlights the macro over a styled markdown base" do
|
||||||
|
[line] = Highlight.highlight("some **bold** [[macro]] words", :markdown_macros, @theme)
|
||||||
|
|
||||||
|
# Bold markdown keeps its base styling while the macro is recoloured.
|
||||||
|
assert :bold in find_span(line, "bold").style.modifiers
|
||||||
|
assert find_span(line, "[[macro").style.fg == {:rgb, 197, 134, 192}
|
||||||
|
assert find_span(line, "]]").style.fg == {:rgb, 197, 134, 192}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Highlight.highlight/3 — :liquid" do
|
||||||
|
test "recolours output tags over the HTML base" do
|
||||||
|
[line] = Highlight.highlight("<div>{{ x }}</div>", :liquid, @theme)
|
||||||
|
|
||||||
|
assert find_span(line, "{{").style.fg == {:rgb, 86, 156, 214}
|
||||||
|
assert find_span(line, "}}").style.fg == {:rgb, 86, 156, 214}
|
||||||
|
# The HTML base styling survives outside the tag.
|
||||||
|
assert find_span(line, "div").style.fg != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "recolours the tag keyword distinctly from identifiers" do
|
||||||
|
[line] = Highlight.highlight("{% if x %}y{% endif %}", :liquid, @theme)
|
||||||
|
|
||||||
|
keyword = find_span(line, "if")
|
||||||
|
assert keyword.style.fg == {:rgb, 86, 156, 214}
|
||||||
|
assert find_span(line, "endif").style.fg == keyword.style.fg
|
||||||
|
refute find_span(line, " x ").style.fg == keyword.style.fg
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Highlight.highlight/3 — line bookkeeping" do
|
||||||
|
test "empty content yields a single empty line" do
|
||||||
|
assert [%Line{spans: []}] = Highlight.highlight("", :markdown_macros, @theme)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "trailing empty lines are padded back to the textarea's line count" do
|
||||||
|
lines = Highlight.highlight("a\n\n", :text, @theme)
|
||||||
|
assert length(lines) == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "spans never contain newline characters" do
|
||||||
|
lines = Highlight.highlight("a\nb\n", :text, @theme)
|
||||||
|
|
||||||
|
for line <- lines, span <- line.spans do
|
||||||
|
refute String.contains?(span.content, "\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "ExRatatui.Widget rendering" do
|
||||||
|
test "emits a wrapping Paragraph, never a bare Textarea, for every highlighted language" do
|
||||||
|
for language <- [:markdown_macros, :liquid, :lua, :text] do
|
||||||
|
{widget, _textarea} = editor_widget("hello", language: language)
|
||||||
|
paragraph = render(widget)
|
||||||
|
|
||||||
|
assert %Paragraph{wrap: true, scroll: {_top, 0}} = paragraph
|
||||||
|
refute match?(%Textarea{}, paragraph)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "splices the cursor cell into the character under the cursor" do
|
||||||
|
{widget, textarea} = editor_widget("hello")
|
||||||
|
:ok = ExRatatui.textarea_handle_key(textarea, "home", [])
|
||||||
|
|
||||||
|
%Paragraph{text: [line | _rest]} = render(widget)
|
||||||
|
|
||||||
|
cursor_span = Enum.find(line.spans, &(&1.style.bg == :cyan))
|
||||||
|
assert cursor_span.content == "h"
|
||||||
|
assert Enum.map_join(line.spans, & &1.content) == "hello"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "appends a cursor cell when the cursor sits at the end of the line" do
|
||||||
|
{widget, _textarea} = editor_widget("hello")
|
||||||
|
|
||||||
|
%Paragraph{text: [line | _rest]} = render(widget)
|
||||||
|
|
||||||
|
cursor_span = List.last(line.spans)
|
||||||
|
assert cursor_span.content == " "
|
||||||
|
assert cursor_span.style.bg == :cyan
|
||||||
|
end
|
||||||
|
|
||||||
|
test "highlights the cursor line with the line highlight style" do
|
||||||
|
{widget, _textarea} =
|
||||||
|
editor_widget("ab\ncd",
|
||||||
|
language: :text,
|
||||||
|
cursor_style: %Style{},
|
||||||
|
line_highlight_style: %Style{bg: :dark_gray}
|
||||||
|
)
|
||||||
|
|
||||||
|
%Paragraph{text: lines} = render(widget)
|
||||||
|
|
||||||
|
# The cursor opens at the end of the second line.
|
||||||
|
assert Enum.all?(Enum.at(lines, 1).spans, &(&1.style.bg == :dark_gray))
|
||||||
|
assert Enum.all?(Enum.at(lines, 0).spans, &(&1.style.bg != :dark_gray))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "scrolls so the cursor visual row stays inside the window" do
|
||||||
|
content = Enum.map_join(1..30, "\n", &"line #{&1}")
|
||||||
|
rect = %Rect{x: 0, y: 0, width: 20, height: 5}
|
||||||
|
{widget, _textarea} = editor_widget(content, rect: rect)
|
||||||
|
|
||||||
|
# The cursor opens on source row 29: 30 visual rows, window height 5.
|
||||||
|
assert %Paragraph{scroll: {25, 0}} = render(widget, rect)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "wrapped source lines count towards the cursor's visual row" do
|
||||||
|
rect = %Rect{x: 0, y: 0, width: 4, height: 2}
|
||||||
|
{widget, _textarea} = editor_widget("abcdefghij", rect: rect)
|
||||||
|
|
||||||
|
# 10 characters in a 4-wide window wrap to 3 visual rows; the cursor
|
||||||
|
# at the end of the line lands on the third.
|
||||||
|
assert %Paragraph{scroll: {1, 0}} = render(widget, rect)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "keeps the scroll offset at zero while the cursor is visible" do
|
||||||
|
{widget, _textarea} = editor_widget("a\nb\nc")
|
||||||
|
|
||||||
|
assert %Paragraph{scroll: {0, 0}} = render(widget)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
200
test/bds/ui/settings_form_test.exs
Normal file
200
test/bds/ui/settings_form_test.exs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
defmodule BDS.UI.SettingsFormTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias BDS.UI.SettingsForm
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
|
||||||
|
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, :manual) end)
|
||||||
|
|
||||||
|
temp_dir = Path.join(System.tmp_dir!(), "bds-settings-form-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "Prefs", data_path: temp_dir})
|
||||||
|
{:ok, _} = BDS.Projects.set_active_project(project.id)
|
||||||
|
|
||||||
|
%{project: project}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp field(form, key), do: Enum.find(form.fields, &(&1.key == key))
|
||||||
|
|
||||||
|
defp values(form, overrides) do
|
||||||
|
form.fields
|
||||||
|
|> Map.new(fn f -> {f.key, f.value} end)
|
||||||
|
|> Map.merge(overrides)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "project" do
|
||||||
|
test "load exposes the project metadata as typed fields", %{project: project} do
|
||||||
|
form = SettingsForm.load("project", project.id)
|
||||||
|
|
||||||
|
assert field(form, "name").value == "Prefs"
|
||||||
|
assert field(form, "name").type == :text
|
||||||
|
assert field(form, "main_language").type == :enum
|
||||||
|
assert "de" in field(form, "main_language").options
|
||||||
|
assert field(form, "semantic_similarity_enabled").type == :bool
|
||||||
|
end
|
||||||
|
|
||||||
|
test "save writes through Metadata and keeps untouched values", %{project: project} do
|
||||||
|
{:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{pico_theme: "jade"})
|
||||||
|
|
||||||
|
form = SettingsForm.load("project", project.id)
|
||||||
|
|
||||||
|
:ok =
|
||||||
|
SettingsForm.save(
|
||||||
|
"project",
|
||||||
|
project.id,
|
||||||
|
values(form, %{
|
||||||
|
"name" => "Renamed",
|
||||||
|
"max_posts_per_page" => "25",
|
||||||
|
"blog_languages" => "en, de",
|
||||||
|
"semantic_similarity_enabled" => true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.name == "Renamed"
|
||||||
|
assert metadata.max_posts_per_page == 25
|
||||||
|
assert metadata.blog_languages == ["en", "de"]
|
||||||
|
assert metadata.semantic_similarity_enabled
|
||||||
|
assert metadata.pico_theme == "jade"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "editor" do
|
||||||
|
test "round-trips the global editor settings", %{project: project} do
|
||||||
|
form = SettingsForm.load("editor", project.id)
|
||||||
|
assert field(form, "default_mode").type == :enum
|
||||||
|
|
||||||
|
:ok =
|
||||||
|
SettingsForm.save(
|
||||||
|
"editor",
|
||||||
|
project.id,
|
||||||
|
values(form, %{"default_mode" => "preview", "wrap_long_lines" => true})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert BDS.Settings.get_global_setting("ui.preferred_editor_mode") == "preview"
|
||||||
|
assert BDS.Settings.get_global_setting("ui.git_diff_word_wrap") == "true"
|
||||||
|
|
||||||
|
reloaded = SettingsForm.load("editor", project.id)
|
||||||
|
assert field(reloaded, "default_mode").value == "preview"
|
||||||
|
assert field(reloaded, "wrap_long_lines").value == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "content" do
|
||||||
|
test "edits category settings and adds new categories", %{project: project} do
|
||||||
|
form = SettingsForm.load("content", project.id)
|
||||||
|
assert field(form, "cat:aside:show_title").type == :bool
|
||||||
|
|
||||||
|
:ok =
|
||||||
|
SettingsForm.save(
|
||||||
|
"content",
|
||||||
|
project.id,
|
||||||
|
values(form, %{
|
||||||
|
"new_category" => "linkroll",
|
||||||
|
"cat:article:render_in_lists" => false,
|
||||||
|
"cat:article:title" => "Articles"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
assert "linkroll" in metadata.categories
|
||||||
|
assert metadata.category_settings["article"]["render_in_lists"] == false
|
||||||
|
assert metadata.category_settings["article"]["title"] == "Articles"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "technology" do
|
||||||
|
test "toggles semantic similarity without wiping the project", %{project: project} do
|
||||||
|
form = SettingsForm.load("technology", project.id)
|
||||||
|
refute field(form, "semantic_similarity_enabled").value
|
||||||
|
|
||||||
|
:ok =
|
||||||
|
SettingsForm.save(
|
||||||
|
"technology",
|
||||||
|
project.id,
|
||||||
|
values(form, %{"semantic_similarity_enabled" => true})
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.semantic_similarity_enabled
|
||||||
|
assert metadata.name == "Prefs"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "publishing" do
|
||||||
|
test "saves the SSH preferences", %{project: project} do
|
||||||
|
form = SettingsForm.load("publishing", project.id)
|
||||||
|
assert field(form, "ssh_mode").options == ["scp", "rsync"]
|
||||||
|
|
||||||
|
:ok =
|
||||||
|
SettingsForm.save(
|
||||||
|
"publishing",
|
||||||
|
project.id,
|
||||||
|
values(form, %{"ssh_host" => "example.org", "ssh_mode" => "rsync"})
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.publishing_preferences["ssh_host"] == "example.org"
|
||||||
|
assert metadata.publishing_preferences["ssh_mode"] == "rsync"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "ai" do
|
||||||
|
test "loads the AI form and saves prompt plus airplane mode", %{project: project} do
|
||||||
|
form = SettingsForm.load("ai", project.id)
|
||||||
|
assert field(form, "offline_mode").type == :bool
|
||||||
|
assert field(form, "system_prompt").type == :text
|
||||||
|
|
||||||
|
:ok =
|
||||||
|
SettingsForm.save(
|
||||||
|
"ai",
|
||||||
|
project.id,
|
||||||
|
values(form, %{"system_prompt" => "be terse", "offline_mode" => true})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert BDS.Settings.get_global_setting("ai.system_prompt") == "be terse"
|
||||||
|
assert BDS.AI.airplane_mode?()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "style" do
|
||||||
|
test "applies a theme without wiping other metadata", %{project: project} do
|
||||||
|
form = SettingsForm.load("style", project.id)
|
||||||
|
assert "jade" in field(form, "pico_theme").options
|
||||||
|
|
||||||
|
:ok = SettingsForm.save("style", project.id, values(form, %{"pico_theme" => "jade"}))
|
||||||
|
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.pico_theme == "jade"
|
||||||
|
assert metadata.name == "Prefs"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "data" do
|
||||||
|
test "shows the data folder read-only and save is a no-op", %{project: project} do
|
||||||
|
form = SettingsForm.load("data", project.id)
|
||||||
|
data_field = field(form, "data_path")
|
||||||
|
assert data_field.type == :info
|
||||||
|
assert data_field.value =~ "bds-settings-form"
|
||||||
|
|
||||||
|
assert SettingsForm.save("data", project.id, %{}) == :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "mcp" do
|
||||||
|
test "lists the agents with supported ones as toggles", %{project: project} do
|
||||||
|
form = SettingsForm.load("mcp", project.id)
|
||||||
|
|
||||||
|
claude = field(form, "mcp:claude_code")
|
||||||
|
assert claude.type == :bool
|
||||||
|
assert is_boolean(claude.value)
|
||||||
|
|
||||||
|
gemini = field(form, "mcp:gemini_cli")
|
||||||
|
assert gemini.type == :info
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -400,6 +400,50 @@ defmodule BDS.UI.ShellTest do
|
|||||||
assert status == 0, output
|
assert status == 0, output
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "monaco hook preserves cursor and selection when applying a remote value" do
|
||||||
|
script = """
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
class FakeTextArea {}
|
||||||
|
globalThis.HTMLTextAreaElement = FakeTextArea;
|
||||||
|
|
||||||
|
const { applyRemoteEditorValue } = await import("./assets/js/hooks/monaco_editor.js");
|
||||||
|
|
||||||
|
const selections = [{ startLineNumber: 3, startColumn: 5, endLineNumber: 3, endColumn: 9 }];
|
||||||
|
const calls = [];
|
||||||
|
const editor = {
|
||||||
|
getSelections: () => selections,
|
||||||
|
setValue: (value) => calls.push(["setValue", value]),
|
||||||
|
setSelections: (restored) => calls.push(["setSelections", restored])
|
||||||
|
};
|
||||||
|
|
||||||
|
applyRemoteEditorValue(editor, "line one\\nline two\\nline three");
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
["setValue", "line one\\nline two\\nline three"],
|
||||||
|
["setSelections", selections]
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Editors without selections still get the value applied.
|
||||||
|
const bareCalls = [];
|
||||||
|
const bareEditor = {
|
||||||
|
getSelections: () => null,
|
||||||
|
setValue: (value) => bareCalls.push(["setValue", value]),
|
||||||
|
setSelections: () => bareCalls.push(["setSelections"])
|
||||||
|
};
|
||||||
|
|
||||||
|
applyRemoteEditorValue(bareEditor, "fresh");
|
||||||
|
assert.deepEqual(bareCalls, [["setValue", "fresh"]]);
|
||||||
|
"""
|
||||||
|
|
||||||
|
{output, status} =
|
||||||
|
System.cmd("node", ["--input-type=module", "--eval", script],
|
||||||
|
cd: "/Users/gb/Projects/bDS2",
|
||||||
|
stderr_to_stdout: true
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status == 0, output
|
||||||
|
end
|
||||||
|
|
||||||
test "monaco ESM build config emits served worker bundles" do
|
test "monaco ESM build config emits served worker bundles" do
|
||||||
mix_exs = File.read!("/Users/gb/Projects/bDS2/mix.exs")
|
mix_exs = File.read!("/Users/gb/Projects/bDS2/mix.exs")
|
||||||
config = File.read!("/Users/gb/Projects/bDS2/config/config.exs")
|
config = File.read!("/Users/gb/Projects/bDS2/config/config.exs")
|
||||||
|
|||||||
@@ -175,6 +175,47 @@ defmodule BDS.UI.SidebarTest do
|
|||||||
assert Enum.map(import_view.items, & &1.title) == ["New Import", "Old Import"]
|
assert Enum.map(import_view.items, & &1.title) == ["New Import", "Old Import"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "posts and media can be filtered to a single ISO date", %{
|
||||||
|
project: project,
|
||||||
|
temp_dir: temp_dir
|
||||||
|
} do
|
||||||
|
on_ms =
|
||||||
|
DateTime.new!(~D[2026-07-04], ~T[10:00:00], "Etc/UTC") |> DateTime.to_unix(:millisecond)
|
||||||
|
|
||||||
|
off_ms =
|
||||||
|
DateTime.new!(~D[2026-07-05], ~T[10:00:00], "Etc/UTC") |> DateTime.to_unix(:millisecond)
|
||||||
|
|
||||||
|
assert {:ok, on_post} = Posts.create_post(%{project_id: project.id, title: "On Date"})
|
||||||
|
assert {:ok, off_post} = Posts.create_post(%{project_id: project.id, title: "Off Date"})
|
||||||
|
update_post_sidebar_row(on_post.id, updated_at: on_ms)
|
||||||
|
update_post_sidebar_row(off_post.id, updated_at: off_ms)
|
||||||
|
|
||||||
|
view = Sidebar.view(project.id, "posts", %{year: 2026, month: 7, day: 4})
|
||||||
|
assert titles_in_section(view, "Drafts") == ["On Date"]
|
||||||
|
assert view.filters.has_active_filters
|
||||||
|
|
||||||
|
on_file = Path.join(temp_dir, "on.txt")
|
||||||
|
off_file = Path.join(temp_dir, "off.txt")
|
||||||
|
File.write!(on_file, "on")
|
||||||
|
File.write!(off_file, "off")
|
||||||
|
|
||||||
|
assert {:ok, on_media} =
|
||||||
|
Media.import_media(%{project_id: project.id, source_path: on_file, title: "Media On"})
|
||||||
|
|
||||||
|
assert {:ok, off_media} =
|
||||||
|
Media.import_media(%{
|
||||||
|
project_id: project.id,
|
||||||
|
source_path: off_file,
|
||||||
|
title: "Media Off"
|
||||||
|
})
|
||||||
|
|
||||||
|
update_media_sidebar_row(on_media.id, updated_at: on_ms)
|
||||||
|
update_media_sidebar_row(off_media.id, updated_at: off_ms)
|
||||||
|
|
||||||
|
media_view = Sidebar.view(project.id, "media", %{year: 2026, month: 7, day: 4})
|
||||||
|
assert Enum.map(media_view.items, & &1.title) == ["Media On"]
|
||||||
|
end
|
||||||
|
|
||||||
defp titles_in_section(view, title) do
|
defp titles_in_section(view, title) do
|
||||||
view.sections
|
view.sections
|
||||||
|> Enum.find(&(&1.title == title))
|
|> Enum.find(&(&1.title == title))
|
||||||
|
|||||||
78
test/bds/ui/task_grouping_test.exs
Normal file
78
test/bds/ui/task_grouping_test.exs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
defmodule BDS.UI.TaskGroupingTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias BDS.UI.TaskGrouping
|
||||||
|
|
||||||
|
defp task(id, status, opts \\ %{}) do
|
||||||
|
%{
|
||||||
|
id: id,
|
||||||
|
name: Map.get(opts, :name, "Task #{id}"),
|
||||||
|
status: status,
|
||||||
|
progress: Map.get(opts, :progress),
|
||||||
|
message: nil,
|
||||||
|
group_id: Map.get(opts, :group_id),
|
||||||
|
group_name: Map.get(opts, :group_name)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "build_task_entries/1" do
|
||||||
|
test "tasks without a group stay single entries in order" do
|
||||||
|
entries = TaskGrouping.build_task_entries([task("a", :running), task("b", :completed)])
|
||||||
|
|
||||||
|
assert [{:single, %{id: "a"}}, {:single, %{id: "b"}}] = entries
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tasks with a group_id are grouped by first-seen order" do
|
||||||
|
entries =
|
||||||
|
TaskGrouping.build_task_entries([
|
||||||
|
task("a", :running, %{group_id: "g1", group_name: "Import"}),
|
||||||
|
task("b", :running),
|
||||||
|
task("c", :pending, %{group_id: "g1", group_name: "Import"}),
|
||||||
|
task("d", :completed, %{group_id: "g2", group_name: "Upload"})
|
||||||
|
])
|
||||||
|
|
||||||
|
assert [
|
||||||
|
{:group, "g1", "Import", [%{id: "a"}, %{id: "c"}]},
|
||||||
|
{:single, %{id: "b"}},
|
||||||
|
{:group, "g2", "Upload", [%{id: "d"}]}
|
||||||
|
] = entries
|
||||||
|
end
|
||||||
|
|
||||||
|
test "group label falls back to the group_id when no group_name is set" do
|
||||||
|
assert [{:group, "g1", "g1", [_]}] =
|
||||||
|
TaskGrouping.build_task_entries([task("a", :running, %{group_id: "g1"})])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "summarize_task_group/1" do
|
||||||
|
test "counts statuses and averages progress contributions" do
|
||||||
|
summary =
|
||||||
|
TaskGrouping.summarize_task_group([
|
||||||
|
task("a", :running, %{progress: 0.5}),
|
||||||
|
task("b", :pending),
|
||||||
|
task("c", :completed)
|
||||||
|
])
|
||||||
|
|
||||||
|
assert summary.total == 3
|
||||||
|
assert summary.running == 1
|
||||||
|
assert summary.pending == 1
|
||||||
|
assert summary.completed == 1
|
||||||
|
assert summary.failed == 0
|
||||||
|
assert summary.cancelled == 0
|
||||||
|
# contributions: 0.5 (running) + 0 (pending) + 1.0 (completed)
|
||||||
|
assert_in_delta summary.progress, 0.5, 0.001
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finished failures and cancellations count as full progress" do
|
||||||
|
summary = TaskGrouping.summarize_task_group([task("a", :failed), task("b", :cancelled)])
|
||||||
|
|
||||||
|
assert summary.progress == 1.0
|
||||||
|
assert summary.failed == 1
|
||||||
|
assert summary.cancelled == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty group summarizes to zero" do
|
||||||
|
assert %{total: 0, progress: +0.0} = TaskGrouping.summarize_task_group([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user