Compare commits
68 Commits
49675a49d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 655069a28a | |||
| 593f1646a1 | |||
| c77450d497 | |||
| 23f05b72f7 | |||
| dd53ca3fbc | |||
| 6c9dd9605b | |||
| 46c239df56 | |||
| 2fd132e827 | |||
| 0f3f1efa08 | |||
| e4fa61ae07 | |||
| 8fe18d108f | |||
| b517672663 | |||
| b016f6d812 | |||
| 505527ae4f | |||
| b6b1d16e54 | |||
| 46fd6c1b85 | |||
| 5433cb59ac | |||
| e027364c0e | |||
| 3675a26407 | |||
| 9a1f301527 | |||
| 59333ac920 | |||
| 381deb417d | |||
| 5801e49dc1 | |||
| a8ca3b643b | |||
| 39bb232b57 | |||
| 824313f106 | |||
| 01b90e00a0 | |||
| c77686250e | |||
| 48e0903542 | |||
| df2bdc2041 | |||
| a3ca44640c | |||
| 3a8fafd2fd | |||
| 16f45d77b2 | |||
| 700ed79c43 | |||
| 6ec61610f1 | |||
| 89859684eb | |||
| dc35518bad | |||
| 4a03fc0ba2 | |||
| 10fa174388 | |||
| 8ee51c6626 | |||
| d31ceb72cb | |||
| c8d2c73a72 | |||
| a772e3abea | |||
| c735c1afa7 | |||
| d85654d4b2 | |||
| ae2e534fdb | |||
| ae1c8703e7 | |||
| d944c07838 | |||
| bb82f1d4a1 | |||
| 7dfcd78abd | |||
| 8e60fe31a5 | |||
| 28398b17f4 | |||
| cf1d4bd46f | |||
| 7fa6e6232c | |||
| fcc574428c | |||
| 1917043c10 | |||
| 4b52ac6156 | |||
| 1487c0337e | |||
| 43f04dcd86 | |||
| c07ba9a4d8 | |||
| ef6c970654 | |||
| e7d5cba01b | |||
| 82c69a2c35 | |||
| b11be9b521 | |||
| 1b0bae37bf | |||
| c2b2b1ff11 | |||
| 5f77641f3f | |||
| ba9634c478 |
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Downloaded JS — exclude from language detection
|
||||||
|
priv/**/*.js linguist-vendored=true
|
||||||
38
AGENTS.md
38
AGENTS.md
@@ -30,6 +30,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
- you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
|
- you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
|
||||||
- you MUST run build, test, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits and clean dialyzer results
|
- you MUST run build, test, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits and clean dialyzer results
|
||||||
- on a headless Linux machine, you have to run tests with this command (if mix test complains about DISPLAX): xvfb-run mix test
|
- on a headless Linux machine, you have to run tests with this command (if mix test complains about DISPLAX): xvfb-run mix test
|
||||||
|
- CSS precedence: Tailwind utilities and the ui-* kit (utilities.css) are in @layer and ALWAYS lose to the unlayered hand-written files (shell.css, panel.css, ...). Convention: hand-written CSS owns appearance, Tailwind utilities in templates own layout — never put display/flex-direction/size rules in hand-written CSS for elements whose layout the template controls, and define each selector in exactly one file.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,6 +56,15 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
|
|
||||||
- Never leave tests failing, even if they appear unrelated to your changes
|
- Never leave tests failing, even if they appear unrelated to your changes
|
||||||
- If a test failure is pre-existing, fix it as part of your current work
|
- If a test failure is pre-existing, fix it as part of your current work
|
||||||
|
- **Always write the full test run to a log file first, then grep that file for failures.** Never run `mix test` in the terminal and try to grep live output — the output is interleaved, truncated, and nearly impossible to parse. Pattern:
|
||||||
|
```
|
||||||
|
mix test 2>&1 > /tmp/test_run.log
|
||||||
|
grep -E "FAILED|^[ ]*1\)" /tmp/test_run.log
|
||||||
|
grep -A 15 "^[ ]*1\)" /tmp/test_run.log # details for the first failure
|
||||||
|
```
|
||||||
|
Work from the log file, not from repeated terminal runs.
|
||||||
|
|
||||||
|
> **Zero failing tests. No exceptions.**
|
||||||
- Run the full test suite (`mix test`) before considering any task complete
|
- Run the full test suite (`mix test`) before considering any task complete
|
||||||
- If you cannot fix a test, explain why and propose a solution
|
- If you cannot fix a test, explain why and propose a solution
|
||||||
|
|
||||||
@@ -115,6 +125,34 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ⚠️ MANDATORY: macOS Bundle Build Order and Blogmark Verification
|
||||||
|
|
||||||
|
**`mix release` copies `priv/static` as-is — it never rebuilds assets.** Always build in this order, or the bundle ships stale JS while the server code is current (this silently broke blogmark deep links in July 2026: the server waited for the client's `shell_ready` event that the stale `app.js` never sent, so every deep link queued forever with no error, no toast, no log):
|
||||||
|
|
||||||
|
```
|
||||||
|
mix assets.deploy
|
||||||
|
MIX_ENV=prod mix release bds --overwrite
|
||||||
|
mix bds.bundle.macos
|
||||||
|
```
|
||||||
|
|
||||||
|
**After building a bundle, verify blogmark deep links end-to-end with:**
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/verify_blogmark_macos.sh [path/to/BDS2.app] # defaults to dist/macos/BDS2.app
|
||||||
|
```
|
||||||
|
|
||||||
|
The script launches the bundle via LaunchServices with an isolated database (`launchctl setenv BDS_DATABASE_PATH ...`), fires `bds2://new-post` links with `open -a` exactly like a browser bookmarklet, and asserts draft posts appear in the database — both for cold start (link launches the app, queued until the shell attaches) and for an already-running app. It refuses to run while any BDS2 instance is up; quit the app first.
|
||||||
|
|
||||||
|
Things learned the hard way, encoded in the script — do not "simplify" them away:
|
||||||
|
|
||||||
|
- Deep links only reach an app **launched through LaunchServices** (`open -a`). A process started by exec'ing `Contents/MacOS/bds2` from a shell never receives Apple Events, so deep links vanish without a trace. For the same reason `open` does not inherit the shell environment — inject env vars with `launchctl setenv` (and unset them afterwards).
|
||||||
|
- To introspect a running bundle, launch it with `RELEASE_DISTRIBUTION=name` and `RELEASE_NODE=bds@127.0.0.1` set (via `launchctl setenv`), then use `.../Contents/Resources/rel/bin/bds rpc '<elixir>'` with the same env. The default `-sname` node is unreachable when the hostname resolves to LAN addresses.
|
||||||
|
- The wx → `Desktop.Env` → `BDS.Desktop.DeepLink` → shell pipeline downstream of the OS can be exercised without LaunchServices via `send(Process.whereis(BDS.Desktop.DeepLink), {:open_url, ~c"bds2://new-post?..."})` over rpc.
|
||||||
|
|
||||||
|
> **Assets before release. Verify blogmarks after bundling. No exceptions.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
|
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
|
||||||
|
|
||||||
**All user-facing text MUST follow proper i18n patterns.**
|
**All user-facing text MUST follow proper i18n patterns.**
|
||||||
|
|||||||
29
API.md
29
API.md
@@ -40,6 +40,7 @@ local meta = bds.meta.get_project_metadata()
|
|||||||
- [app.get_default_project_path](#appget_default_project_path)
|
- [app.get_default_project_path](#appget_default_project_path)
|
||||||
- [app.get_system_language](#appget_system_language)
|
- [app.get_system_language](#appget_system_language)
|
||||||
- [app.get_title_bar_metrics](#appget_title_bar_metrics)
|
- [app.get_title_bar_metrics](#appget_title_bar_metrics)
|
||||||
|
- [app.log](#applog)
|
||||||
- [app.notify_renderer_ready](#appnotify_renderer_ready)
|
- [app.notify_renderer_ready](#appnotify_renderer_ready)
|
||||||
- [app.open_folder](#appopen_folder)
|
- [app.open_folder](#appopen_folder)
|
||||||
- [app.read_project_metadata](#appread_project_metadata)
|
- [app.read_project_metadata](#appread_project_metadata)
|
||||||
@@ -150,7 +151,7 @@ local result = bds.app.get_default_project_path()
|
|||||||
|
|
||||||
### app.get_system_language
|
### app.get_system_language
|
||||||
|
|
||||||
Return the current UI locale.
|
Return the current UI locale (the server-side UI language setting, falling back to the OS locale when unset).
|
||||||
|
|
||||||
**Parameters**
|
**Parameters**
|
||||||
|
|
||||||
@@ -202,6 +203,30 @@ nil -- or
|
|||||||
local result = bds.app.get_title_bar_metrics()
|
local result = bds.app.get_title_bar_metrics()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### app.log
|
||||||
|
|
||||||
|
Append a line to the script output stream. Multiple arguments are joined with spaces. Output appears in the desktop app's Output panel (and on stdout in the CLI); Lua's global `print` is routed the same way.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- text (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `boolean`
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.log("value")
|
||||||
|
```
|
||||||
|
|
||||||
### app.notify_renderer_ready
|
### app.notify_renderer_ready
|
||||||
|
|
||||||
Notify the host application that the renderer is ready.
|
Notify the host application that the renderer is ready.
|
||||||
@@ -3443,7 +3468,7 @@ local result = bds.meta.sync_on_startup()
|
|||||||
|
|
||||||
### meta.update_project_metadata
|
### meta.update_project_metadata
|
||||||
|
|
||||||
Update metadata for the current project.
|
Update metadata for the current project. Keys omitted from updates keep their current values.
|
||||||
|
|
||||||
**Parameters**
|
**Parameters**
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
- [Generating and publishing](#generating-and-publishing)
|
- [Generating and publishing](#generating-and-publishing)
|
||||||
- [Typical editorial workflows](#typical-editorial-workflows)
|
- [Typical editorial workflows](#typical-editorial-workflows)
|
||||||
- [Working fully offline](#working-fully-offline)
|
- [Working fully offline](#working-fully-offline)
|
||||||
|
- [Running a headless server and the terminal UI](#running-a-headless-server-and-the-terminal-ui)
|
||||||
- [Troubleshooting and recovery](#troubleshooting-and-recovery)
|
- [Troubleshooting and recovery](#troubleshooting-and-recovery)
|
||||||
- [Team conventions](#team-conventions)
|
- [Team conventions](#team-conventions)
|
||||||
|
|
||||||
@@ -443,6 +444,29 @@ When AI is involved, airplane mode determines which automatic actions are allowe
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Running a headless server and the terminal UI
|
||||||
|
|
||||||
|
bDS2 can run without a window on a server (for example a Linux VPS) and be used from several places at once. Start it with `BDS_MODE=server`; the same release binary that runs the desktop app then runs headless. All clients connect through one SSH port (default 2222) — the web endpoint itself stays private on the server.
|
||||||
|
|
||||||
|
Access is controlled by SSH keys, not passwords. On first start the server creates an `ssh/` folder next to its database (for example `~/Library/Application Support/BDS2/ssh/` on macOS) containing the host key and an empty `authorized_keys` file. Paste the public keys of every machine that may connect into `authorized_keys`, one per line — the same file format OpenSSH uses.
|
||||||
|
|
||||||
|
To work in a terminal, connect with plain `ssh -p 2222 user@server`: the terminal UI opens directly. You can browse posts, media, templates, scripts, and tags, create and edit posts, publish, preview images, and run the one-shot AI actions. The status line at the bottom always shows the available keys.
|
||||||
|
|
||||||
|
To work in the desktop app against a remote server, use **File → Connect to Server…** and enter `user@host` (or `user@host:port`). The app connects with the SSH key from its own local `ssh/` folder and shows the remote workspace in the window; **File → Disconnect from Server** returns to the local workspace. A plain browser works too, through a manual SSH tunnel (`ssh -p 2222 -L 4010:127.0.0.1:4010 user@server`, then open `http://127.0.0.1:4010`).
|
||||||
|
|
||||||
|
Everything you see stays synchronized: when a script, another client, or a pipeline changes content on the server, every connected terminal and window updates. The interface language is a server-side setting — changing it in any client changes it for all of them.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- `BDS_MODE=server` runs the same app headless; only the SSH port is exposed.
|
||||||
|
- Access is public-key only: manage `authorized_keys` in the server's `ssh/` folder.
|
||||||
|
- `ssh` gives the terminal UI; **File → Connect to Server…** gives the desktop app; both use the same keys.
|
||||||
|
- All connected clients stay synchronized and share one interface language.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Troubleshooting and recovery
|
## Troubleshooting and recovery
|
||||||
|
|
||||||
If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote.
|
If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote.
|
||||||
|
|||||||
81
README.md
81
README.md
@@ -12,6 +12,7 @@ The major architectural rework is in place.
|
|||||||
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
|
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
|
||||||
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
|
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
|
||||||
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
|
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
|
||||||
|
- The app boots in three modes: the desktop app, a headless server (SSH-served terminal UI + tunneled GUI), and a local TUI — see [Headless Server Mode And Terminal UI](#headless-server-mode-and-terminal-ui).
|
||||||
|
|
||||||
The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
|
The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ If you are tracing UI behavior, start there first:
|
|||||||
|
|
||||||
- LiveView event routing, workbench state, overlays, and menu handling live in the desktop shell modules.
|
- LiveView event routing, workbench state, overlays, and menu handling live in the desktop shell modules.
|
||||||
- HEEx templates under the same tree now own most common layout and state styling.
|
- HEEx templates under the same tree now own most common layout and state styling.
|
||||||
- Monaco remains a vendor drop under [priv/ui/monaco/](/Users/gb/Projects/bDS2/priv/ui/monaco).
|
- Monaco is bundled as an ESM bundle via esbuild into `priv/static/assets/monaco.js` and `monaco.css` (source entry at `assets/js/monaco_entry.js`).
|
||||||
|
|
||||||
### Domain Modules
|
### Domain Modules
|
||||||
|
|
||||||
@@ -156,6 +157,84 @@ mix dialyzer
|
|||||||
mix assets.build
|
mix assets.build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Headless Server Mode And Terminal UI
|
||||||
|
|
||||||
|
Since issue #26 the app has three boot modes, selected with the `BDS_MODE`
|
||||||
|
environment variable (any release or dev boot; default is the desktop app):
|
||||||
|
|
||||||
|
- `desktop` — the native desktop app, unchanged.
|
||||||
|
- `server` — headless: no window, loopback-only HTTP endpoint, CLI sync
|
||||||
|
watcher, and an SSH daemon serving the terminal UI. Works on macOS,
|
||||||
|
Windows, and Linux (no display needed).
|
||||||
|
- `tui` — the headless server plus a TUI attached to the launching terminal.
|
||||||
|
|
||||||
|
### Build and run a headless server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIX_ENV=prod mix release bds --overwrite
|
||||||
|
BDS_MODE=server _build/prod/rel/bds/bin/bds start
|
||||||
|
```
|
||||||
|
|
||||||
|
The SSH daemon listens on port `2222` (`BDS_SSH_PORT` or
|
||||||
|
`config :bds, :server, ssh_port:` to change). Authentication is public-key
|
||||||
|
only: on first boot the server generates a host key and an empty
|
||||||
|
`authorized_keys` (mode 600) in `ssh/` next to the database (default
|
||||||
|
`~/Library/Application Support/BDS2/ssh/`, or under `BDS_DATABASE_PATH`).
|
||||||
|
Add your public key there, then connect from any terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 2222 <server-host> # opens the TUI
|
||||||
|
```
|
||||||
|
|
||||||
|
Each SSH connection gets its own TUI session on the server; only terminal
|
||||||
|
cells cross the wire. All clients stay synchronized through the domain
|
||||||
|
event bus (`BDS.Events`) — a post created by a pipeline or another client
|
||||||
|
appears in every open shell.
|
||||||
|
|
||||||
|
### TUI keys
|
||||||
|
|
||||||
|
`↑/↓` or `j/k` navigate · `enter` open · `n` new post · `1-5` switch view
|
||||||
|
(posts/media/templates/scripts/tags) · `:` command prompt (metadata
|
||||||
|
diff, validate site, force render, rebuilds, …; `:?` lists all) ·
|
||||||
|
metadata-diff/site-validation reports open as panels — `enter` applies
|
||||||
|
the whole report (repair from files / incremental apply), `esc` cancels ·
|
||||||
|
`r` refresh · in the editor:
|
||||||
|
`ctrl+s` save, `ctrl+p` publish, `ctrl+e` word-wrapped preview,
|
||||||
|
`ctrl+t` title, `ctrl+l` language, `ctrl+g` AI suggestions
|
||||||
|
(airplane-mode gated), `esc` back · `ctrl+q` quit.
|
||||||
|
Images preview inline via the terminal image protocol. The UI language is
|
||||||
|
the server-side setting shared by every client.
|
||||||
|
|
||||||
|
### Remote GUI
|
||||||
|
|
||||||
|
The HTTP endpoint stays bound to `127.0.0.1` and is never exposed; GUI
|
||||||
|
clients tunnel through the same SSH daemon and keys:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 2222 -L 4010:127.0.0.1:4010 -N <server-host> &
|
||||||
|
open http://127.0.0.1:4010
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the desktop app directly: **File → Connect to Server…** takes
|
||||||
|
`user@host[:port]`, connects with the client key from the same local
|
||||||
|
`ssh/` directory (public-key auth, trust-on-first-use `known_hosts`),
|
||||||
|
tunnels to the server, and points the window at it. **File → Disconnect
|
||||||
|
from Server** returns to the local workspace.
|
||||||
|
|
||||||
|
## Monaco Editor
|
||||||
|
|
||||||
|
Monaco is bundled via esbuild using its ESM entry point (`monaco-editor/esm/vs/editor/editor.main.js`). The
|
||||||
|
main bundle is built as part of `mix assets.build` and lives at `priv/static/assets/monaco.js` and
|
||||||
|
`monaco.css`; ESM worker bundles live under `priv/static/assets/monaco/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mix monaco.version # show current monaco-editor version
|
||||||
|
mix monaco.update 0.55.1 # update to a specific version and rebuild the bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
The update task runs `npm install monaco-editor@<version>`, cleans the old bundle, and rebuilds via esbuild.
|
||||||
|
See https://www.npmjs.com/package/monaco-editor for available versions.
|
||||||
|
|
||||||
Notes for developers:
|
Notes for developers:
|
||||||
|
|
||||||
- Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.
|
- Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.
|
||||||
|
|||||||
223
TUI_EDITOR.md
Normal file
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.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
@source "../css";
|
@source "../css";
|
||||||
@source "../js";
|
@source "../js";
|
||||||
@source "../../lib/bds/desktop";
|
@source "../../lib/bds/desktop";
|
||||||
|
@source "../../deps/live_toast/lib";
|
||||||
|
|
||||||
@import "./tokens.css";
|
@import "./tokens.css";
|
||||||
@import "./shell.css";
|
@import "./shell.css";
|
||||||
|
|||||||
@@ -646,12 +646,6 @@
|
|||||||
max-width: 22px;
|
max-width: 22px;
|
||||||
max-height: 22px;
|
max-height: 22px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--vscode-button-background);
|
|
||||||
color: var(--vscode-button-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-panel .chat-send-button:hover:not(:disabled) {
|
|
||||||
background: var(--vscode-button-hoverBackground);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel .chat-send-button:disabled {
|
.chat-panel .chat-send-button:disabled {
|
||||||
|
|||||||
@@ -14,8 +14,7 @@
|
|||||||
|
|
||||||
.editor-main,
|
.editor-main,
|
||||||
.editor-meta,
|
.editor-meta,
|
||||||
.panel-shell,
|
.panel-shell {
|
||||||
.assistant-card {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,8 +49,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar-button:hover,
|
.editor-toolbar-button:hover {
|
||||||
.panel-tab:hover {
|
|
||||||
background: var(--vscode-toolbar-hoverBackground);
|
background: var(--vscode-toolbar-hoverBackground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,6 +615,14 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-suggestion-section-label {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--vscode-descriptionForeground, #a6a6a6);
|
||||||
|
}
|
||||||
|
|
||||||
.post-editor .tag-suggestion.create-new {
|
.post-editor .tag-suggestion.create-new {
|
||||||
border-top: 1px solid var(--vscode-widget-border, #454545);
|
border-top: 1px solid var(--vscode-widget-border, #454545);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -742,8 +748,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .editor-toolbar {
|
.post-editor .editor-toolbar {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -751,23 +756,27 @@
|
|||||||
|
|
||||||
.post-editor .editor-toolbar-left {
|
.post-editor .editor-toolbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .editor-toolbar-center {
|
.post-editor .editor-toolbar-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ponytail: right group keeps its content width so buttons never wrap or
|
||||||
|
overlap the mode toggle; the left label shrinks instead */
|
||||||
.post-editor .editor-toolbar-right {
|
.post-editor .editor-toolbar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .editor-mode-toggle {
|
.post-editor .editor-mode-toggle {
|
||||||
@@ -809,38 +818,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .gallery-button {
|
|
||||||
padding: 4px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--vscode-button-secondaryBackground);
|
|
||||||
color: var(--vscode-button-secondaryForeground);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-editor .gallery-button:hover {
|
|
||||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-editor .insert-post-link-button,
|
|
||||||
.post-editor .insert-media-button {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--vscode-button-secondaryBackground);
|
|
||||||
color: var(--vscode-button-secondaryForeground);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-editor .insert-post-link-button:hover,
|
|
||||||
.post-editor .insert-media-button:hover {
|
|
||||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-editor .editor-preview {
|
.post-editor .editor-preview {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: var(--vscode-input-background);
|
background-color: var(--vscode-input-background);
|
||||||
@@ -929,8 +906,7 @@
|
|||||||
.post-editor .editor-header,
|
.post-editor .editor-header,
|
||||||
.scripts-view-shell .ui-editor-header,
|
.scripts-view-shell .ui-editor-header,
|
||||||
.templates-view-shell .ui-editor-header,
|
.templates-view-shell .ui-editor-header,
|
||||||
.post-editor .metadata-toggle-header,
|
.post-editor .metadata-toggle-header {
|
||||||
.post-editor .editor-toolbar {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -946,7 +922,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .editor-toolbar-right,
|
|
||||||
.post-editor .ui-editor-actions,
|
.post-editor .ui-editor-actions,
|
||||||
.scripts-view-shell .ui-editor-actions,
|
.scripts-view-shell .ui-editor-actions,
|
||||||
.templates-view-shell .ui-editor-actions {
|
.templates-view-shell .ui-editor-actions {
|
||||||
|
|||||||
@@ -67,30 +67,12 @@
|
|||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-analysis button,
|
|
||||||
.import-analysis select {
|
.import-analysis select {
|
||||||
border: 1px solid var(--vscode-button-border, transparent);
|
border: 1px solid var(--vscode-button-border, transparent);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-analysis button {
|
|
||||||
background: var(--vscode-button-secondaryBackground, var(--vscode-button-background));
|
|
||||||
color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground));
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-analysis button:hover:not(:disabled) {
|
|
||||||
background: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-analyze-btn,
|
|
||||||
.import-execute-btn {
|
|
||||||
background: var(--vscode-button-background) !important;
|
|
||||||
color: var(--vscode-button-foreground) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-analysis button:disabled {
|
.import-analysis button:disabled {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -110,7 +92,7 @@
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border: 2px solid var(--vscode-descriptionForeground);
|
border: 2px solid var(--vscode-descriptionForeground);
|
||||||
border-top-color: var(--vscode-button-background);
|
border-top-color: var(--vscode-button-background, #0e639c);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: import-spinner-rotate 0.8s linear infinite;
|
animation: import-spinner-rotate 0.8s linear infinite;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -106,8 +106,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-testid="media-editor"] .media-preview-image img {
|
[data-testid="media-editor"] .media-preview-image img {
|
||||||
width: 100%;
|
max-width: 100%;
|
||||||
height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -128,20 +128,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-testid="media-editor"] .add-link-btn {
|
|
||||||
background: var(--vscode-button-secondaryBackground);
|
|
||||||
border: none;
|
|
||||||
color: var(--vscode-button-secondaryForeground);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-testid="media-editor"] .add-link-btn:hover {
|
|
||||||
background: var(--vscode-button-secondaryHoverBackground);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-testid="media-editor"] .post-picker {
|
[data-testid="media-editor"] .post-picker {
|
||||||
background: var(--vscode-dropdown-background);
|
background: var(--vscode-dropdown-background);
|
||||||
border: 1px solid var(--vscode-dropdown-border);
|
border: 1px solid var(--vscode-dropdown-border);
|
||||||
|
|||||||
@@ -537,3 +537,8 @@
|
|||||||
border: 1px solid var(--vscode-panel-border);
|
border: 1px solid var(--vscode-panel-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.monaco-diff-editor-instance {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
.language-picker-modal-backdrop,
|
.language-picker-modal-backdrop,
|
||||||
.confirm-delete-modal-backdrop,
|
.confirm-delete-modal-backdrop,
|
||||||
.confirm-dialog-overlay,
|
.confirm-dialog-overlay,
|
||||||
|
.git-run-backdrop,
|
||||||
.gallery-overlay,
|
.gallery-overlay,
|
||||||
.lightbox-overlay {
|
.lightbox-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
.language-picker-modal,
|
.language-picker-modal,
|
||||||
.confirm-delete-modal,
|
.confirm-delete-modal,
|
||||||
.confirm-dialog,
|
.confirm-dialog,
|
||||||
|
.git-run-modal,
|
||||||
.gallery-overlay-content {
|
.gallery-overlay-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -44,6 +46,76 @@
|
|||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.git-run-modal {
|
||||||
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status.running {
|
||||||
|
color: #d7ba7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status.ok {
|
||||||
|
color: #4ec9b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status.fail {
|
||||||
|
color: #f48771;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-output {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 160px;
|
||||||
|
max-height: 60vh;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-stdout {
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-stderr {
|
||||||
|
color: #f48771;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-suggestions-modal,
|
.ai-suggestions-modal,
|
||||||
.language-picker-modal,
|
.language-picker-modal,
|
||||||
.confirm-delete-modal,
|
.confirm-delete-modal,
|
||||||
@@ -257,8 +329,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-suggestions-modal-footer,
|
.ai-suggestions-modal-footer,
|
||||||
.confirm-delete-modal-footer,
|
.confirm-delete-modal-footer {
|
||||||
.confirm-dialog-actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -266,42 +337,12 @@
|
|||||||
border-top: 1px solid #3c3c3c;
|
border-top: 1px solid #3c3c3c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-cancel,
|
|
||||||
.button-delete,
|
|
||||||
.button-apply,
|
|
||||||
.confirm-dialog-actions button,
|
|
||||||
.insert-modal-submit,
|
|
||||||
.language-picker-row,
|
.language-picker-row,
|
||||||
.shared-popover-entry,
|
.shared-popover-entry,
|
||||||
.colour-swatch {
|
.colour-swatch {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-cancel,
|
|
||||||
.confirm-dialog-actions button,
|
|
||||||
.insert-modal-submit {
|
|
||||||
border: 1px solid #4c4c4c;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
background: transparent;
|
|
||||||
color: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-apply,
|
|
||||||
.confirm-dialog-actions .primary,
|
|
||||||
.insert-modal-submit {
|
|
||||||
background: #0e639c;
|
|
||||||
border-color: #0e639c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-delete {
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
background: #c73c3c;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insert-modal-tabs {
|
.insert-modal-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 0 -20px;
|
margin: 0 -20px;
|
||||||
@@ -336,3 +377,78 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insert-modal-results {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-item:hover {
|
||||||
|
background: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-title {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-meta {
|
||||||
|
color: #9d9d9d;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-status {
|
||||||
|
padding: 16px 20px;
|
||||||
|
color: #9d9d9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-external {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-external .insert-modal-input {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-label {
|
||||||
|
color: #9d9d9d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-submit {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Alert modal (errors) — reuses .confirm-delete-modal with a tone accent. */
|
||||||
|
.alert-modal-error {
|
||||||
|
border-top: 3px solid #ff6b6b;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,14 +8,11 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tabs {
|
.panel-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tab {
|
.panel-tab {
|
||||||
@@ -116,7 +113,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-prompt-form,
|
.assistant-sidebar-prompt-form,
|
||||||
.assistant-sidebar-welcome,
|
|
||||||
.assistant-sidebar-transcript {
|
.assistant-sidebar-transcript {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -142,12 +138,6 @@
|
|||||||
|
|
||||||
.assistant-sidebar-start-button {
|
.assistant-sidebar-start-button {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
border: 1px solid var(--vscode-button-border, transparent);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--vscode-button-background);
|
|
||||||
color: var(--vscode-button-foreground);
|
|
||||||
padding: 7px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-start-button:disabled {
|
.assistant-sidebar-start-button:disabled {
|
||||||
@@ -155,7 +145,6 @@
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-card,
|
|
||||||
.assistant-sidebar-message {
|
.assistant-sidebar-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -254,6 +243,12 @@
|
|||||||
background-color: var(--vscode-sideBar-background);
|
background-color: var(--vscode-sideBar-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* .panel-entry (shell.css) forces flex-direction: column and, being unlayered,
|
||||||
|
beats the Tailwind row utilities on this button — force the row here. */
|
||||||
|
.task-group-toggle {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
.output-entry {
|
.output-entry {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
|||||||
@@ -402,24 +402,6 @@
|
|||||||
border-bottom: 1px solid var(--vscode-panel-border);
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tab {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--vscode-descriptionForeground);
|
|
||||||
padding: 0 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tab.active {
|
|
||||||
color: var(--vscode-tab-activeForeground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-close {
|
.panel-close {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -446,8 +428,7 @@
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-entry,
|
.panel-entry {
|
||||||
.assistant-card {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -779,19 +760,6 @@
|
|||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-toolbar button {
|
|
||||||
padding: 9px 14px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--panel-3);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-meta {
|
.editor-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -799,18 +767,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-meta-card,
|
.editor-meta-card,
|
||||||
.assistant-card,
|
|
||||||
.panel-entry {
|
.panel-entry {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header,
|
|
||||||
.assistant-header,
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,6 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-card span,
|
|
||||||
.panel-entry span,
|
.panel-entry span,
|
||||||
.editor-meta-row span,
|
.editor-meta-row span,
|
||||||
.editor-subtitle,
|
.editor-subtitle,
|
||||||
@@ -777,8 +776,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip.active {
|
.filter-chip.active {
|
||||||
background-color: var(--vscode-button-background);
|
background-color: var(--vscode-button-background, #0e639c);
|
||||||
color: var(--vscode-button-foreground);
|
color: var(--vscode-button-foreground, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip.has-color {
|
.filter-chip.has-color {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@
|
|||||||
--vscode-editorGroupHeader-tabsBorder: #1e1e1e;
|
--vscode-editorGroupHeader-tabsBorder: #1e1e1e;
|
||||||
--vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31);
|
--vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31);
|
||||||
--vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31);
|
--vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31);
|
||||||
|
--vscode-button-secondaryBackground: rgba(255, 255, 255, 0.08);
|
||||||
|
--vscode-button-secondaryForeground: #cccccc;
|
||||||
|
--vscode-button-secondaryHoverBackground: #4a4d51;
|
||||||
--vscode-foreground: #cccccc;
|
--vscode-foreground: #cccccc;
|
||||||
--vscode-descriptionForeground: #858585;
|
--vscode-descriptionForeground: #858585;
|
||||||
--vscode-panel-border: #80808059;
|
--vscode-panel-border: #80808059;
|
||||||
@@ -130,12 +133,13 @@ button.compact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button.primary {
|
button.primary {
|
||||||
background-color: var(--vscode-button-background);
|
color: var(--vscode-button-foreground, #ffffff);
|
||||||
|
background-color: var(--vscode-button-background, #0e639c);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.primary:hover {
|
button.primary:hover {
|
||||||
background-color: var(--vscode-button-hoverBackground);
|
background-color: var(--vscode-button-hoverBackground, #1177bb);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.success {
|
button.success {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "../utils/shortcuts.js";
|
} from "../utils/shortcuts.js";
|
||||||
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
|
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
|
||||||
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
|
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
|
||||||
|
import { copyTextToClipboard } from "../utils/clipboard.js";
|
||||||
|
|
||||||
export const AppShell = {
|
export const AppShell = {
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -179,6 +180,10 @@ export const AppShell = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.handleEvent("clipboard", ({ text }) => {
|
||||||
|
copyTextToClipboard(text || "");
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
||||||
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
|
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
|
||||||
this.el.addEventListener("load", this.handleThumbnailLoad, true);
|
this.el.addEventListener("load", this.handleThumbnailLoad, true);
|
||||||
@@ -186,6 +191,9 @@ export const AppShell = {
|
|||||||
this.el.addEventListener("change", this.handleChange);
|
this.el.addEventListener("change", this.handleChange);
|
||||||
syncMediaThumbnailState(this.el);
|
syncMediaThumbnailState(this.el);
|
||||||
this.restoreStoredWorkbenchSession();
|
this.restoreStoredWorkbenchSession();
|
||||||
|
// Rehydration is done (any stored-session push is ordered before this):
|
||||||
|
// tell the server it may now deliver queued deep links.
|
||||||
|
this.pushEvent("shell_ready", {});
|
||||||
},
|
},
|
||||||
|
|
||||||
updated() {
|
updated() {
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { ColourPicker } from "./colour_picker.js";
|
|||||||
import { MenuEditorTree } from "./menu_editor_tree.js";
|
import { MenuEditorTree } from "./menu_editor_tree.js";
|
||||||
import { MonacoEditor } from "./monaco_editor.js";
|
import { MonacoEditor } from "./monaco_editor.js";
|
||||||
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
|
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
|
||||||
|
import { createLiveToastHook } from "live_toast";
|
||||||
|
|
||||||
export const Hooks = {
|
export const Hooks = {
|
||||||
|
LiveToast: createLiveToastHook(),
|
||||||
AppShell,
|
AppShell,
|
||||||
SidebarInteractions,
|
SidebarInteractions,
|
||||||
SettingsSectionScroll,
|
SettingsSectionScroll,
|
||||||
|
|||||||
@@ -5,6 +5,66 @@ import {
|
|||||||
unregisterMonacoEditor
|
unregisterMonacoEditor
|
||||||
} from "../monaco/services.js";
|
} from "../monaco/services.js";
|
||||||
|
|
||||||
|
const WEBKIT_TEXTAREA_ARROW_COMMANDS = {
|
||||||
|
ArrowLeft: "cursorLeft",
|
||||||
|
ArrowRight: "cursorRight",
|
||||||
|
ArrowUp: "cursorUp",
|
||||||
|
ArrowDown: "cursorDown"
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWebKitEngine = () => {
|
||||||
|
const userAgent = globalThis.navigator?.userAgent || "";
|
||||||
|
return userAgent.includes("AppleWebKit") && !userAgent.includes("Chrome") && !userAgent.includes("Chromium");
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMonacoInputArea = (target) =>
|
||||||
|
target instanceof HTMLTextAreaElement && target.classList.contains("inputarea");
|
||||||
|
|
||||||
|
export const webKitTextAreaArrowCommand = (event) => {
|
||||||
|
if (event?.altKey || event?.ctrlKey || event?.metaKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = WEBKIT_TEXTAREA_ARROW_COMMANDS[event?.key];
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return event.shiftKey ? `${command}Select` : command;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Applying a remote value via setValue resets the cursor to the buffer start,
|
||||||
|
// which then makes follow-up actions (like link inserts targeting the current
|
||||||
|
// selection) land at position 1,1. Capture and restore the selection so the
|
||||||
|
// cursor survives server-driven reconciles; Monaco clamps out-of-range
|
||||||
|
// positions to the new content.
|
||||||
|
export const applyRemoteEditorValue = (editor, value) => {
|
||||||
|
const selections = editor.getSelections ? editor.getSelections() : null;
|
||||||
|
editor.setValue(value);
|
||||||
|
|
||||||
|
if (selections && selections.length > 0 && editor.setSelections) {
|
||||||
|
editor.setSelections(selections);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bridgeWebKitTextAreaArrowKey = (editor, event) => {
|
||||||
|
if (!editor || !isMonacoInputArea(event.target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = webKitTextAreaArrowCommand(event);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
editor.trigger("keyboard", command, { source: "keyboard" });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
export const MonacoEditor = {
|
export const MonacoEditor = {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
||||||
@@ -17,6 +77,10 @@ export const MonacoEditor = {
|
|||||||
this.isApplyingRemoteUpdate = false;
|
this.isApplyingRemoteUpdate = false;
|
||||||
this.lastKnownValue = this.textarea?.value || "";
|
this.lastKnownValue = this.textarea?.value || "";
|
||||||
|
|
||||||
|
this.handleWebKitTextAreaArrowKey = (event) => {
|
||||||
|
bridgeWebKitTextAreaArrowKey(this.editor, event);
|
||||||
|
};
|
||||||
|
|
||||||
this.syncEditorFromTextarea = () => {
|
this.syncEditorFromTextarea = () => {
|
||||||
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
||||||
|
|
||||||
@@ -26,10 +90,28 @@ export const MonacoEditor = {
|
|||||||
|
|
||||||
const value = this.textarea.value || "";
|
const value = this.textarea.value || "";
|
||||||
|
|
||||||
|
// ponytail: ignore the server echoing back our own last-emitted value;
|
||||||
|
// only a genuinely remote value (translation switch, etc.) should reset
|
||||||
|
// the editor. Otherwise mid-typing echoes clobber the cursor to offset 0.
|
||||||
|
if (value === this.lastKnownValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// While the user is actively typing, Monaco owns the content. Never let a
|
||||||
|
// server re-render (autosave, dirty toggle, metadata) yank it out from
|
||||||
|
// under the cursor — reconcile only once the editor is blurred.
|
||||||
|
if (this.editor.hasTextFocus()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.editor.getValue() !== value) {
|
if (this.editor.getValue() !== value) {
|
||||||
this.isApplyingRemoteUpdate = true;
|
this.isApplyingRemoteUpdate = true;
|
||||||
this.editor.setValue(value);
|
|
||||||
this.isApplyingRemoteUpdate = false;
|
try {
|
||||||
|
applyRemoteEditorValue(this.editor, value);
|
||||||
|
} finally {
|
||||||
|
this.isApplyingRemoteUpdate = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastKnownValue = value;
|
this.lastKnownValue = value;
|
||||||
@@ -232,6 +314,10 @@ export const MonacoEditor = {
|
|||||||
this.el.addEventListener("dragover", this.handleDragOver);
|
this.el.addEventListener("dragover", this.handleDragOver);
|
||||||
this.el.addEventListener("drop", this.handleDrop);
|
this.el.addEventListener("drop", this.handleDrop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWebKitEngine()) {
|
||||||
|
this.host.addEventListener("keydown", this.handleWebKitTextAreaArrowKey, true);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Failed to load Monaco editor", error);
|
console.error("Failed to load Monaco editor", error);
|
||||||
@@ -273,6 +359,8 @@ export const MonacoEditor = {
|
|||||||
this.el.removeEventListener("drop", this.handleDrop);
|
this.el.removeEventListener("drop", this.handleDrop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.host?.removeEventListener("keydown", this.handleWebKitTextAreaArrowKey, true);
|
||||||
|
|
||||||
unregisterMonacoEditor(this.editorId || this.el.id);
|
unregisterMonacoEditor(this.editorId || this.el.id);
|
||||||
this.editor?.dispose();
|
this.editor?.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,54 @@
|
|||||||
import { loadScript } from "../utils/script_loader.js";
|
|
||||||
import { ensureMonacoTheme } from "./theme.js";
|
import { ensureMonacoTheme } from "./theme.js";
|
||||||
import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js";
|
import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js";
|
||||||
|
|
||||||
let monacoLoaderPromise;
|
let monacoLoaderPromise;
|
||||||
const monacoEditors = new Map();
|
const monacoEditors = new Map();
|
||||||
|
|
||||||
|
const monacoWorkerUrls = {
|
||||||
|
editor: "/assets/monaco/editor.worker.js",
|
||||||
|
css: "/assets/monaco/css.worker.js",
|
||||||
|
html: "/assets/monaco/html.worker.js",
|
||||||
|
json: "/assets/monaco/json.worker.js",
|
||||||
|
ts: "/assets/monaco/ts.worker.js"
|
||||||
|
};
|
||||||
|
|
||||||
|
const workerNameForLanguage = (label) => {
|
||||||
|
switch (label) {
|
||||||
|
case "css":
|
||||||
|
case "scss":
|
||||||
|
case "less":
|
||||||
|
return "css";
|
||||||
|
case "html":
|
||||||
|
case "handlebars":
|
||||||
|
case "razor":
|
||||||
|
return "html";
|
||||||
|
case "json":
|
||||||
|
return "json";
|
||||||
|
case "typescript":
|
||||||
|
case "javascript":
|
||||||
|
return "ts";
|
||||||
|
default:
|
||||||
|
return "editor";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureMonacoEnvironment = () => {
|
||||||
|
if (globalThis.MonacoEnvironment?.getWorker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.MonacoEnvironment = {
|
||||||
|
...globalThis.MonacoEnvironment,
|
||||||
|
getWorker(_workerId, label) {
|
||||||
|
const workerName = workerNameForLanguage(label);
|
||||||
|
return new Worker(monacoWorkerUrls[workerName], { name: label, type: "module" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const loadMonaco = () => {
|
export const loadMonaco = () => {
|
||||||
|
ensureMonacoEnvironment();
|
||||||
|
|
||||||
if (window.monaco?.editor) {
|
if (window.monaco?.editor) {
|
||||||
ensureMonacoTheme(window.monaco);
|
ensureMonacoTheme(window.monaco);
|
||||||
registerLiquidLanguage(window.monaco);
|
registerLiquidLanguage(window.monaco);
|
||||||
@@ -17,20 +60,17 @@ export const loadMonaco = () => {
|
|||||||
return monacoLoaderPromise;
|
return monacoLoaderPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
monacoLoaderPromise = loadScript("/monaco/vs/loader.js")
|
monacoLoaderPromise = import("/assets/monaco.js")
|
||||||
.then(
|
.then((monaco) => {
|
||||||
() =>
|
window.monaco = monaco;
|
||||||
new Promise((resolve, reject) => {
|
if (!monaco.editor) throw new Error("Monaco loaded but monaco.editor is missing");
|
||||||
window.require.config({ paths: { vs: "/monaco/vs" } });
|
ensureMonacoTheme(monaco);
|
||||||
window.require(["vs/editor/editor.main"], () => {
|
registerLiquidLanguage(monaco);
|
||||||
ensureMonacoTheme(window.monaco);
|
registerMarkdownWithMacrosLanguage(monaco);
|
||||||
registerLiquidLanguage(window.monaco);
|
return monaco;
|
||||||
registerMarkdownWithMacrosLanguage(window.monaco);
|
})
|
||||||
resolve(window.monaco);
|
|
||||||
}, reject);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
console.error("Monaco load failed:", error);
|
||||||
monacoLoaderPromise = null;
|
monacoLoaderPromise = null;
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|||||||
3
assets/js/monaco_entry.js
Normal file
3
assets/js/monaco_entry.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// ESM entry point for Monaco. Bundled by esbuild into priv/static/assets/monaco.js
|
||||||
|
// editor.main.js loads the API AND registers all bundled languages (api.js only exposes the API)
|
||||||
|
export * from "monaco-editor/esm/vs/editor/editor.main.js";
|
||||||
1
assets/js/monaco_workers/css.worker.js
Normal file
1
assets/js/monaco_workers/css.worker.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/css/css.worker.js";
|
||||||
1
assets/js/monaco_workers/editor.worker.js
Normal file
1
assets/js/monaco_workers/editor.worker.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/editor/editor.worker.js";
|
||||||
1
assets/js/monaco_workers/html.worker.js
Normal file
1
assets/js/monaco_workers/html.worker.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/html/html.worker.js";
|
||||||
1
assets/js/monaco_workers/json.worker.js
Normal file
1
assets/js/monaco_workers/json.worker.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/json/json.worker.js";
|
||||||
1
assets/js/monaco_workers/ts.worker.js
Normal file
1
assets/js/monaco_workers/ts.worker.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/typescript/ts.worker.js";
|
||||||
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,33 +0,0 @@
|
|||||||
export const loadScript = (src) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const existing = document.querySelector(`script[src="${src}"]`);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
if (existing.dataset.loaded === "true") {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.addEventListener("load", () => resolve(), { once: true });
|
|
||||||
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
|
|
||||||
once: true
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement("script");
|
|
||||||
script.src = src;
|
|
||||||
script.async = true;
|
|
||||||
script.addEventListener(
|
|
||||||
"load",
|
|
||||||
() => {
|
|
||||||
script.dataset.loaded = "true";
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
|
|
||||||
once: true
|
|
||||||
});
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
@@ -25,8 +25,13 @@ config :bds, :desktop,
|
|||||||
window_min_size: {800, 600},
|
window_min_size: {800, 600},
|
||||||
title: "Blogging Desktop Server"
|
title: "Blogging Desktop Server"
|
||||||
|
|
||||||
|
config :bds, :server, ssh_port: 2222
|
||||||
|
|
||||||
config :bds, BDS.Desktop.Endpoint,
|
config :bds, BDS.Desktop.Endpoint,
|
||||||
url: [host: "127.0.0.1"],
|
url: [host: "127.0.0.1"],
|
||||||
|
# Any-port loopback origins: the remote-GUI SSH tunnel terminates on an
|
||||||
|
# ephemeral local port, so the websocket origin port varies per connect.
|
||||||
|
check_origin: ["//127.0.0.1", "//localhost"],
|
||||||
adapter: Bandit.PhoenixAdapter,
|
adapter: Bandit.PhoenixAdapter,
|
||||||
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
|
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
|
||||||
pubsub_server: BDS.PubSub,
|
pubsub_server: BDS.PubSub,
|
||||||
@@ -55,6 +60,34 @@ config :esbuild,
|
|||||||
--external:/images/*
|
--external:/images/*
|
||||||
),
|
),
|
||||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||||
|
],
|
||||||
|
monaco: [
|
||||||
|
cd: Path.expand("../assets", __DIR__),
|
||||||
|
args: ~w(
|
||||||
|
js/monaco_entry.js
|
||||||
|
--bundle
|
||||||
|
--target=es2022
|
||||||
|
--format=esm
|
||||||
|
--outfile=../priv/static/assets/monaco.js
|
||||||
|
--loader:.ttf=dataurl
|
||||||
|
),
|
||||||
|
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
|
||||||
|
],
|
||||||
|
monaco_workers: [
|
||||||
|
cd: Path.expand("../assets", __DIR__),
|
||||||
|
args: ~w(
|
||||||
|
js/monaco_workers/editor.worker.js
|
||||||
|
js/monaco_workers/css.worker.js
|
||||||
|
js/monaco_workers/html.worker.js
|
||||||
|
js/monaco_workers/json.worker.js
|
||||||
|
js/monaco_workers/ts.worker.js
|
||||||
|
--bundle
|
||||||
|
--target=es2022
|
||||||
|
--format=esm
|
||||||
|
--outdir=../priv/static/assets/monaco
|
||||||
|
--loader:.ttf=dataurl
|
||||||
|
),
|
||||||
|
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
|
||||||
]
|
]
|
||||||
|
|
||||||
config :bds, :scripting,
|
config :bds, :scripting,
|
||||||
|
|||||||
@@ -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") ||
|
||||||
|
|||||||
@@ -54,13 +54,21 @@ defmodule BDS.AI do
|
|||||||
model = MapUtils.attr(attrs, :model)
|
model = MapUtils.attr(attrs, :model)
|
||||||
api_key = MapUtils.attr(attrs, :api_key)
|
api_key = MapUtils.attr(attrs, :api_key)
|
||||||
|
|
||||||
with :ok <- put_setting("ai.#{kind_key}.url", url),
|
with :ok <- put_or_delete_setting("ai.#{kind_key}.url", url),
|
||||||
:ok <- put_setting("ai.#{kind_key}.model", model),
|
:ok <- put_or_delete_setting("ai.#{kind_key}.model", model),
|
||||||
:ok <- put_secret("ai.#{kind_key}.api_key", api_key, backend) do
|
:ok <- put_or_delete_secret("ai.#{kind_key}.api_key", api_key, backend) do
|
||||||
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
|
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A nil field means "clear it" — writing nil into put_setting/2 would raise a
|
||||||
|
# FunctionClauseError and crash the caller (the preferences panel).
|
||||||
|
defp put_or_delete_setting(key, nil), do: delete_setting(key)
|
||||||
|
defp put_or_delete_setting(key, value) when is_binary(value), do: put_setting(key, value)
|
||||||
|
|
||||||
|
defp put_or_delete_secret(key, nil, _backend), do: delete_setting(encrypted_key(key))
|
||||||
|
defp put_or_delete_secret(key, value, backend), do: put_secret(key, value, backend)
|
||||||
|
|
||||||
@spec get_endpoint(endpoint_kind(), keyword()) ::
|
@spec get_endpoint(endpoint_kind(), keyword()) ::
|
||||||
{:ok, endpoint() | nil} | {:error, term()}
|
{:ok, endpoint() | nil} | {:error, term()}
|
||||||
def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do
|
def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do
|
||||||
|
|||||||
@@ -5,6 +5,27 @@ defmodule BDS.Application do
|
|||||||
|
|
||||||
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
|
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
|
||||||
|
|
||||||
|
# Children that depend on the boot mode (issue #26): desktop mode runs the
|
||||||
|
# wx window shell, server mode runs headless behind the SSH daemon. Both
|
||||||
|
# start the loopback HTTP endpoint and the CLI sync watcher.
|
||||||
|
def mode_children(:server, _env) do
|
||||||
|
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher, BDS.Server]
|
||||||
|
end
|
||||||
|
|
||||||
|
# CLI mode (issue #25): a one-shot command run against the shared database.
|
||||||
|
# No HTTP listener, SSH daemon, window, or sync watcher — the CLI is the
|
||||||
|
# external writer the watcher in a concurrently running app listens for.
|
||||||
|
def mode_children(:cli, _env), do: []
|
||||||
|
|
||||||
|
# Local TUI: the headless server plus a TUI on the launching terminal, so
|
||||||
|
# a GUI (via tunnel) and the local terminal can work in parallel. Quitting
|
||||||
|
# this TUI stops the whole VM — the terminal is the app in this mode.
|
||||||
|
# SSH-served TUI sessions never get this flag and stay session-local.
|
||||||
|
def mode_children(:tui, env),
|
||||||
|
do: mode_children(:server, env) ++ [{BDS.TUI, [stop_vm_on_exit: true]}]
|
||||||
|
|
||||||
|
def mode_children(:desktop, env), do: desktop_children(env)
|
||||||
|
|
||||||
def desktop_children(env \\ nil)
|
def desktop_children(env \\ nil)
|
||||||
|
|
||||||
def desktop_children(:test) do
|
def desktop_children(:test) do
|
||||||
@@ -41,7 +62,7 @@ defmodule BDS.Application do
|
|||||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||||
BDS.Scripting.JobSupervisor,
|
BDS.Scripting.JobSupervisor,
|
||||||
BDS.Embeddings.Index
|
BDS.Embeddings.Index
|
||||||
] ++ embedding_children() ++ desktop_children(current_env())
|
] ++ embedding_children() ++ mode_children(boot_mode(), current_env())
|
||||||
|
|
||||||
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||||
Supervisor.start_link(children, opts)
|
Supervisor.start_link(children, opts)
|
||||||
@@ -61,10 +82,26 @@ defmodule BDS.Application do
|
|||||||
Application.get_env(:bds, :current_env_override) || @compiled_env
|
Application.get_env(:bds, :current_env_override) || @compiled_env
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Desktop mode without a graphical display boots the TUI instead
|
||||||
|
# (issue #33); the log line explains the surprise mode switch.
|
||||||
|
defp boot_mode do
|
||||||
|
resolved = BDS.Server.mode()
|
||||||
|
effective = BDS.Server.effective_mode(resolved)
|
||||||
|
|
||||||
|
if effective != resolved do
|
||||||
|
require Logger
|
||||||
|
Logger.info("No graphical display available, starting the terminal UI instead")
|
||||||
|
end
|
||||||
|
|
||||||
|
effective
|
||||||
|
end
|
||||||
|
|
||||||
defp desktop_window_children do
|
defp desktop_window_children do
|
||||||
if desktop_automation?() do
|
if desktop_automation?() do
|
||||||
[]
|
[]
|
||||||
else
|
else
|
||||||
|
disable_auto_window_menu()
|
||||||
|
|
||||||
window_opts =
|
window_opts =
|
||||||
BDS.Desktop.MainWindow.window_options(
|
BDS.Desktop.MainWindow.window_options(
|
||||||
menubar: BDS.Desktop.MenuBar,
|
menubar: BDS.Desktop.MenuBar,
|
||||||
@@ -75,11 +112,23 @@ defmodule BDS.Application do
|
|||||||
[
|
[
|
||||||
{Desktop.Window, window_opts},
|
{Desktop.Window, window_opts},
|
||||||
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher),
|
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher),
|
||||||
{BDS.Desktop.DeepLink, []}
|
{BDS.Desktop.DeepLink, []},
|
||||||
|
{BDS.Desktop.RemoteConnection, []}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# wxOSX injects an untracked "Window" menu into the menubar at install time,
|
||||||
|
# which races elixir-desktop's async menu populate and ends up at an
|
||||||
|
# unstable position. We disable it and ship our own :window menu group
|
||||||
|
# (BDS.UI.MenuBar) placed between Blog and Help.
|
||||||
|
defp disable_auto_window_menu do
|
||||||
|
if :os.type() == {:unix, :darwin} do
|
||||||
|
:wx.set_env(Desktop.Env.wx_env())
|
||||||
|
:wxMenuBar.setAutoWindowMenu(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp desktop_automation? do
|
defp desktop_automation? do
|
||||||
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
|
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ defmodule BDS.Blogmark do
|
|||||||
@scheme "bds2"
|
@scheme "bds2"
|
||||||
@new_post_action "new-post"
|
@new_post_action "new-post"
|
||||||
|
|
||||||
|
# Input-hardening limits mirrored from the legacy bDS app (shared/blogmark.ts).
|
||||||
|
@max_title_length 200
|
||||||
|
@max_url_length 2048
|
||||||
|
@control_chars ~r/[\x00-\x1F\x7F]/
|
||||||
|
|
||||||
@type candidate :: %{required(String.t()) => term()}
|
@type candidate :: %{required(String.t()) => term()}
|
||||||
|
|
||||||
@type receive_result :: %{
|
@type receive_result :: %{
|
||||||
@@ -34,7 +39,8 @@ defmodule BDS.Blogmark do
|
|||||||
Parses a `bds2://new-post` deep link into a post candidate map.
|
Parses a `bds2://new-post` deep link into a post candidate map.
|
||||||
|
|
||||||
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
|
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
|
||||||
`tags` and `categories`, or an error when the scheme or action is unsupported.
|
`tags`, `categories` and optional `project_id`, or an error when the scheme
|
||||||
|
or action is unsupported.
|
||||||
"""
|
"""
|
||||||
@spec parse_deep_link(String.t()) ::
|
@spec parse_deep_link(String.t()) ::
|
||||||
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
|
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
|
||||||
@@ -54,10 +60,27 @@ defmodule BDS.Blogmark do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the `project_id` a deep link targets, or `nil` when the link carries
|
||||||
|
none or cannot be parsed. Used by the shell to switch to the link's project
|
||||||
|
before importing.
|
||||||
|
"""
|
||||||
|
@spec deep_link_project_id(String.t()) :: String.t() | nil
|
||||||
|
def deep_link_project_id(url) when is_binary(url) do
|
||||||
|
case parse_deep_link(url) do
|
||||||
|
{:ok, %{"project_id" => project_id}} -> project_id
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Receives a blogmark deep link for `project_id`: parses it, runs the transform
|
Receives a blogmark deep link for `project_id`: parses it, runs the transform
|
||||||
pipeline, and creates a draft post from the resulting candidate.
|
pipeline, and creates a draft post from the resulting candidate.
|
||||||
|
|
||||||
|
When the deep link URL carries a `project_id` query parameter, it takes
|
||||||
|
precedence over the passed `project_id` so the bookmarklet can target a
|
||||||
|
specific project regardless of which project is currently active.
|
||||||
|
|
||||||
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
|
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
|
||||||
budget-enforced transform messages and `errors` records any failed transforms.
|
budget-enforced transform messages and `errors` records any failed transforms.
|
||||||
"""
|
"""
|
||||||
@@ -66,10 +89,12 @@ defmodule BDS.Blogmark do
|
|||||||
def receive_deep_link(project_id, url, opts \\ [])
|
def receive_deep_link(project_id, url, opts \\ [])
|
||||||
when is_binary(project_id) and is_binary(url) and is_list(opts) do
|
when is_binary(project_id) and is_binary(url) and is_list(opts) do
|
||||||
with {:ok, candidate} <- parse_deep_link(url),
|
with {:ok, candidate} <- parse_deep_link(url),
|
||||||
|
target_project <- resolve_target_project(project_id, candidate),
|
||||||
|
candidate <- apply_default_content(candidate),
|
||||||
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
|
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
|
||||||
Transforms.run(project_id, candidate, opts),
|
Transforms.run(target_project, candidate, opts),
|
||||||
data <- apply_default_category(project_id, data),
|
data <- apply_default_category(target_project, data),
|
||||||
{:ok, post} <- create_draft(project_id, data) do
|
{:ok, post} <- create_draft(target_project, data) do
|
||||||
{:ok, %{post: post, toasts: toasts, errors: errors}}
|
{:ok, %{post: post, toasts: toasts, errors: errors}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -77,15 +102,102 @@ defmodule BDS.Blogmark do
|
|||||||
defp candidate_from_query(query) do
|
defp candidate_from_query(query) do
|
||||||
params = URI.decode_query(query || "")
|
params = URI.decode_query(query || "")
|
||||||
|
|
||||||
|
project_id =
|
||||||
|
case Map.get(params, "project_id") do
|
||||||
|
nil -> nil
|
||||||
|
"" -> nil
|
||||||
|
id -> id
|
||||||
|
end
|
||||||
|
|
||||||
|
url = sanitize_http_url(Map.get(params, "url"))
|
||||||
|
|
||||||
%{
|
%{
|
||||||
"title" => Map.get(params, "title", "") |> to_string(),
|
"title" => sanitize_title(Map.get(params, "title"), url_host(url)),
|
||||||
"url" => optional(params, "url"),
|
"url" => url,
|
||||||
"content" => optional(params, "content"),
|
"content" => optional(params, "content"),
|
||||||
"tags" => list_param(params, "tags"),
|
"tags" => list_param(params, "tags"),
|
||||||
"categories" => list_param(params, "categories")
|
"categories" => list_param(params, "categories"),
|
||||||
|
"project_id" => project_id
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Strip control characters, trim, cap length; fall back to the URL host when
|
||||||
|
# blank — mirrors bDS sanitizeTitle. Title/url are single-line fields, so
|
||||||
|
# control-character stripping is safe here (unlike the multi-line body).
|
||||||
|
defp sanitize_title(raw, fallback_host) do
|
||||||
|
title =
|
||||||
|
raw
|
||||||
|
|> to_string()
|
||||||
|
|> strip_control()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.slice(0, @max_title_length)
|
||||||
|
|
||||||
|
if title == "", do: fallback_host || "", else: title
|
||||||
|
end
|
||||||
|
|
||||||
|
# Accept only http(s) URLs, strip credentials and fragment, enforce a length
|
||||||
|
# cap; anything else collapses to nil so it can never reach the post body.
|
||||||
|
defp sanitize_http_url(raw) when is_binary(raw) do
|
||||||
|
trimmed = raw |> strip_control() |> String.trim()
|
||||||
|
|
||||||
|
case URI.parse(trimmed) do
|
||||||
|
%URI{scheme: scheme, host: host} = uri
|
||||||
|
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
|
||||||
|
normalized = URI.to_string(%URI{uri | userinfo: nil, fragment: nil})
|
||||||
|
if String.length(normalized) <= @max_url_length, do: normalized, else: nil
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_http_url(_other), do: nil
|
||||||
|
|
||||||
|
defp url_host(url) when is_binary(url), do: URI.parse(url).host
|
||||||
|
defp url_host(_other), do: nil
|
||||||
|
|
||||||
|
defp strip_control(value), do: String.replace(value, @control_chars, "")
|
||||||
|
|
||||||
|
# If the deep link carries a project_id, route to that project; otherwise
|
||||||
|
# use the currently active project passed by the caller. This lets the
|
||||||
|
# bookmarklet target a specific project even when a different one is open.
|
||||||
|
defp resolve_target_project(_caller_project_id, %{"project_id" => pid}) when is_binary(pid) do
|
||||||
|
pid
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_target_project(project_id, _candidate), do: project_id
|
||||||
|
|
||||||
|
# Match the legacy bDS app: when the bookmarklet supplies no body, seed the
|
||||||
|
# post content with a markdown link to the bookmarked page so transforms run
|
||||||
|
# against the same input. Explicit content always wins.
|
||||||
|
defp apply_default_content(candidate) do
|
||||||
|
with nil <- optional_string(Map.get(candidate, "content")),
|
||||||
|
url when is_binary(url) <- optional_string(Map.get(candidate, "url")) do
|
||||||
|
title = Map.get(candidate, "title", "")
|
||||||
|
Map.put(candidate, "content", blogmark_markdown_link(title, url))
|
||||||
|
else
|
||||||
|
_ -> candidate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp blogmark_markdown_link(title, url) do
|
||||||
|
"[" <> escape_markdown_link_text(title) <> "](" <> url <> ")"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mirrors bDS escapeMarkdownLinkText: backslash first, then brackets/parens,
|
||||||
|
# then collapse newlines to spaces.
|
||||||
|
defp escape_markdown_link_text(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.replace("\\", "\\\\")
|
||||||
|
|> String.replace("[", "\\[")
|
||||||
|
|> String.replace("]", "\\]")
|
||||||
|
|> String.replace("(", "\\(")
|
||||||
|
|> String.replace(")", "\\)")
|
||||||
|
|> String.replace(~r/\r?\n/, " ")
|
||||||
|
end
|
||||||
|
|
||||||
defp optional(params, key) do
|
defp optional(params, key) do
|
||||||
case Map.get(params, key) do
|
case Map.get(params, key) do
|
||||||
nil -> nil
|
nil -> nil
|
||||||
|
|||||||
@@ -54,13 +54,14 @@ defmodule BDS.BoundedAtoms do
|
|||||||
:fill_missing_translations,
|
:fill_missing_translations,
|
||||||
:find_duplicates,
|
:find_duplicates,
|
||||||
:generate_sitemap,
|
:generate_sitemap,
|
||||||
|
:force_render_site,
|
||||||
:validate_site,
|
:validate_site,
|
||||||
:upload_site,
|
:upload_site,
|
||||||
:documentation,
|
:documentation,
|
||||||
:api_documentation,
|
:api_documentation,
|
||||||
:close_tab
|
:close_tab
|
||||||
]
|
]
|
||||||
@menu_actions MenuBar.default_groups(dev_mode?: true)
|
@menu_actions MenuBar.default_groups(dev_mode?: true, window_menu?: true)
|
||||||
|> Enum.flat_map(fn group ->
|
|> Enum.flat_map(fn group ->
|
||||||
Enum.flat_map(group.items, fn
|
Enum.flat_map(group.items, fn
|
||||||
%{separator: true} -> []
|
%{separator: true} -> []
|
||||||
|
|||||||
335
lib/bds/cli.ex
Normal file
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
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
defmodule BDS.Desktop.DeepLink do
|
defmodule BDS.Desktop.DeepLink do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
|
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
|
||||||
shell (spec: script.allium `BlogmarkReceived`).
|
live shell (spec: script.allium `BlogmarkReceived`).
|
||||||
|
|
||||||
On macOS the `BDS2.app` bundle registers `bds2://` as a custom URL scheme via
|
On macOS the `BDS2.app` bundle registers `bds2://` as a custom URL scheme via
|
||||||
the `CFBundleURLTypes` entry in its `Info.plist` (built by `BDS.MacBundle` /
|
the `CFBundleURLTypes` entry in its `Info.plist` (built by `BDS.MacBundle` /
|
||||||
`mix bds.bundle.macos`). When the browser bookmarklet navigates to
|
`mix bds.bundle.macos`). When the browser bookmarklet navigates to
|
||||||
`bds2://new-post?title=&url=`, the OS launches/raises the app and `Desktop.Env`
|
`bds2://new-post?title=&url=`, the OS launches/raises the app and `Desktop.Env`
|
||||||
delivers an `{:open_url, [url]}` event. This GenServer subscribes to those
|
relays erlang `:wx`'s `{:open_url, Url}` app event (where `Url` is the URL as a
|
||||||
events and forwards recognised `bds2://` links to the live shell over PubSub,
|
charlist). This GenServer subscribes to those events and forwards recognised
|
||||||
where `BDS.Blogmark` turns them into draft posts.
|
`bds2://` links to the live shell.
|
||||||
|
|
||||||
|
Cold start matters here: when the bookmarklet *launches* the app, the deep
|
||||||
|
link arrives during startup, before the LiveView shell's websocket has
|
||||||
|
connected. Such links are queued and replayed the moment a shell registers via
|
||||||
|
`attach/2`; while a shell is connected, links are delivered to it directly.
|
||||||
|
|
||||||
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
|
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
|
||||||
installs do not contend for the same registration.
|
installs do not contend for the same registration.
|
||||||
@@ -19,8 +24,6 @@ defmodule BDS.Desktop.DeepLink do
|
|||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias BDS.CliSync.Watcher
|
|
||||||
|
|
||||||
@scheme "bds2://"
|
@scheme "bds2://"
|
||||||
|
|
||||||
def child_spec(opts) do
|
def child_spec(opts) do
|
||||||
@@ -31,32 +34,69 @@ defmodule BDS.Desktop.DeepLink do
|
|||||||
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
|
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@doc """
|
||||||
def init(opts) do
|
Registers the live shell `pid` as the deep-link sink and immediately replays
|
||||||
pubsub = Keyword.get(opts, :pubsub, BDS.PubSub)
|
any links that arrived before a shell was connected. Future links are sent to
|
||||||
topic = Keyword.get(opts, :topic, Watcher.topic())
|
this pid until it dies, after which links queue again.
|
||||||
|
"""
|
||||||
subscribe_to_env()
|
@spec attach(pid(), GenServer.server()) :: :ok
|
||||||
|
def attach(shell_pid, server \\ __MODULE__) when is_pid(shell_pid) do
|
||||||
{:ok, %{pubsub: pubsub, topic: topic}}
|
GenServer.call(server, {:attach, shell_pid})
|
||||||
end
|
end
|
||||||
|
|
||||||
# Desktop.Env delivers OS events as {event_name, args} tuples.
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:open_url, [url | _rest]}, state) when is_binary(url) do
|
def init(_opts) do
|
||||||
{:noreply, route(url, state)}
|
subscribe_to_env()
|
||||||
|
{:ok, %{shell: nil, pending: []}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:attach, pid}, _from, state) do
|
||||||
|
Process.monitor(pid)
|
||||||
|
|
||||||
|
state.pending
|
||||||
|
|> Enum.reverse()
|
||||||
|
|> Enum.each(&send(pid, {:blogmark_deep_link, &1}))
|
||||||
|
|
||||||
|
{:reply, :ok, %{state | shell: pid, pending: []}}
|
||||||
|
end
|
||||||
|
|
||||||
|
# erlang's :wx delivers macOS app events as `{:open_url, Url}` where `Url` is
|
||||||
|
# the URL *string itself* — a charlist on current OTP, not a list of URLs
|
||||||
|
# (see `wx:subscribe_events/0`). Desktop.Env forwards it unchanged, so the
|
||||||
|
# payload must be normalised to a binary before routing.
|
||||||
|
@impl true
|
||||||
|
def handle_info({:open_url, payload}, state) do
|
||||||
|
{:noreply, route(to_url(payload), state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:DOWN, _ref, :process, pid, _reason}, %{shell: pid} = state) do
|
||||||
|
{:noreply, %{state | shell: nil}}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info(_message, state), do: {:noreply, state}
|
def handle_info(_message, state), do: {:noreply, state}
|
||||||
|
|
||||||
defp route(url, state) do
|
# Accept the charlist wx actually sends, a bare binary, or a [binary | _] list
|
||||||
if String.starts_with?(url, @scheme) do
|
# (the shape Desktop.Env's docs imply), normalising all to a binary URL.
|
||||||
Phoenix.PubSub.broadcast(state.pubsub, state.topic, {:blogmark_deep_link, url})
|
defp to_url(value) when is_binary(value), do: value
|
||||||
else
|
defp to_url([first | _]) when is_binary(first), do: first
|
||||||
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
|
defp to_url(value) when is_list(value), do: List.to_string(value)
|
||||||
end
|
defp to_url(_other), do: ""
|
||||||
|
|
||||||
state
|
defp route(url, state) do
|
||||||
|
cond do
|
||||||
|
not String.starts_with?(url, @scheme) ->
|
||||||
|
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
|
||||||
|
state
|
||||||
|
|
||||||
|
is_pid(state.shell) and Process.alive?(state.shell) ->
|
||||||
|
send(state.shell, {:blogmark_deep_link, url})
|
||||||
|
state
|
||||||
|
|
||||||
|
true ->
|
||||||
|
# No shell connected yet (cold start) — hold the link until one attaches.
|
||||||
|
%{state | pending: [url | state.pending]}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Desktop.Env is only present when the wx desktop adapter is running. Guard the
|
# Desktop.Env is only present when the wx desktop adapter is running. Guard the
|
||||||
|
|||||||
52
lib/bds/desktop/dialogs.ex
Normal file
52
lib/bds/desktop/dialogs.ex
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
defmodule BDS.Desktop.Dialogs do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
# Native single-line text prompt, same osascript approach as
|
||||||
|
# BDS.Desktop.FilePicker. Returns {:ok, text} | :cancel | {:error, map}.
|
||||||
|
def prompt_text(message, default \\ "") when is_binary(message) do
|
||||||
|
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
|
||||||
|
:cancel
|
||||||
|
else
|
||||||
|
case :os.type() do
|
||||||
|
{:unix, :darwin} -> prompt_text_macos(message, default)
|
||||||
|
_other -> {:error, %{message: "Dialogs are only supported on macOS desktop"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def alert(title, message) when is_binary(title) and is_binary(message) do
|
||||||
|
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" or :os.type() != {:unix, :darwin} do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
script = "display alert \"#{escape(title)}\" message \"#{escape(message)}\""
|
||||||
|
_ = System.cmd("osascript", ["-e", script], stderr_to_stdout: true)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prompt_text_macos(message, default) do
|
||||||
|
script =
|
||||||
|
"text returned of (display dialog \"#{escape(message)}\" default answer \"#{escape(default)}\")"
|
||||||
|
|
||||||
|
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
|
||||||
|
{output, 0} -> {:ok, String.trim(output)}
|
||||||
|
{output, _status} -> normalize_failure(output)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_failure(output) do
|
||||||
|
message = String.trim(output)
|
||||||
|
|
||||||
|
if message == "" or String.contains?(String.downcase(message), "canceled") do
|
||||||
|
:cancel
|
||||||
|
else
|
||||||
|
{:error, %{message: message}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp escape(value) do
|
||||||
|
value
|
||||||
|
|> String.replace("\\", "\\\\")
|
||||||
|
|> String.replace("\"", "\\\"")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -17,19 +17,14 @@ defmodule BDS.Desktop.Endpoint do
|
|||||||
plug(Plug.Static,
|
plug(Plug.Static,
|
||||||
at: "/assets",
|
at: "/assets",
|
||||||
from: {:bds, "priv/static/assets"},
|
from: {:bds, "priv/static/assets"},
|
||||||
only: ["app.css", "app.js"]
|
only: ["app.css", "app.js", "monaco.css", "monaco.js", "monaco"]
|
||||||
)
|
|
||||||
|
|
||||||
plug(Plug.Static,
|
|
||||||
at: "/monaco",
|
|
||||||
from: {:bds, "priv/ui/monaco"},
|
|
||||||
only: ["vs"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
plug(BDS.Desktop.Router)
|
plug(BDS.Desktop.Router)
|
||||||
|
|
||||||
defp maybe_require_desktop_auth(conn, _opts) do
|
defp maybe_require_desktop_auth(conn, _opts) do
|
||||||
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do
|
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] or
|
||||||
|
not BDS.Server.desktop_auth_required?() do
|
||||||
conn
|
conn
|
||||||
else
|
else
|
||||||
Desktop.Auth.call(conn, [])
|
Desktop.Auth.call(conn, [])
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ defmodule BDS.Desktop.Layouts do
|
|||||||
<title><%= @page_title || "Blogging Desktop Server" %></title>
|
<title><%= @page_title || "Blogging Desktop Server" %></title>
|
||||||
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
|
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
|
||||||
<link phx-track-static rel="stylesheet" href="/assets/app.css" />
|
<link phx-track-static rel="stylesheet" href="/assets/app.css" />
|
||||||
|
<link phx-track-static rel="stylesheet" href="/assets/monaco.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<%= @inner_content %>
|
<%= @inner_content %>
|
||||||
|
|||||||
@@ -8,16 +8,45 @@ defmodule BDS.Desktop.MediaController do
|
|||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
|
|
||||||
def thumbnail(conn, %{"media_id" => media_id} = params) do
|
@web_image_types ~w(image/jpeg image/png image/webp image/gif image/svg+xml image/avif)
|
||||||
case active_media_thumbnail(media_id, Map.get(params, "size")) do
|
|
||||||
{:ok, content_type, path} ->
|
|
||||||
conn
|
|
||||||
|> Plug.Conn.put_resp_content_type(content_type)
|
|
||||||
|> Plug.Conn.send_file(200, path)
|
|
||||||
|
|
||||||
:error ->
|
def thumbnail(conn, %{"media_id" => media_id} = params) do
|
||||||
send_resp(conn, 404, "not found")
|
send_media(conn, active_media_thumbnail(media_id, Map.get(params, "size")))
|
||||||
|
end
|
||||||
|
|
||||||
|
def file(conn, %{"media_id" => media_id}) do
|
||||||
|
send_media(conn, active_media_file(media_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_media(conn, {:ok, content_type, path}) do
|
||||||
|
conn
|
||||||
|
|> Plug.Conn.put_resp_content_type(content_type)
|
||||||
|
|> Plug.Conn.send_file(200, path)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_media(conn, :error), do: send_resp(conn, 404, "not found")
|
||||||
|
|
||||||
|
# Serves the original image at full resolution; formats browsers cannot
|
||||||
|
# render inline fall back to the large thumbnail.
|
||||||
|
defp active_media_file(media_id) do
|
||||||
|
with %{} = project <- Projects.get_active_project(),
|
||||||
|
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
|
||||||
|
true <- media.project_id == project.id,
|
||||||
|
true <- media.mime_type in @web_image_types,
|
||||||
|
absolute_path = Path.join(Projects.project_data_dir(project), media.file_path),
|
||||||
|
true <- File.exists?(absolute_path) do
|
||||||
|
{:ok, media.mime_type, absolute_path}
|
||||||
|
else
|
||||||
|
_other -> active_media_thumbnail(media_id, "large")
|
||||||
end
|
end
|
||||||
|
rescue
|
||||||
|
error in [Exqlite.Error, DBConnection.OwnershipError] ->
|
||||||
|
if match?(%Exqlite.Error{}, error) and
|
||||||
|
not String.contains?(Exception.message(error), "no such table") do
|
||||||
|
reraise error, __STACKTRACE__
|
||||||
|
end
|
||||||
|
|
||||||
|
:error
|
||||||
end
|
end
|
||||||
|
|
||||||
defp active_media_thumbnail(media_id, size) do
|
defp active_media_thumbnail(media_id, size) do
|
||||||
|
|||||||
@@ -25,12 +25,7 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
def mount(menu) do
|
def mount(menu) do
|
||||||
UILocale.put(ShellData.ui_language())
|
UILocale.put(ShellData.ui_language())
|
||||||
|
|
||||||
{:ok,
|
{:ok, Desktop.Menu.assign(menu, :groups, groups(native_menu_opts()))}
|
||||||
Desktop.Menu.assign(
|
|
||||||
menu,
|
|
||||||
:groups,
|
|
||||||
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
|
|
||||||
)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -72,6 +67,35 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("connect_server", menu) do
|
||||||
|
prompt =
|
||||||
|
dgettext("ui", "Server address (user@host or user@host:port), public-key auth:")
|
||||||
|
|
||||||
|
with {:ok, target} <- BDS.Desktop.Dialogs.prompt_text(prompt, "user@host"),
|
||||||
|
{:ok, url} <- BDS.Desktop.RemoteConnection.connect(target) do
|
||||||
|
Window.show(BDS.Desktop.MainWindow.window_id(), url)
|
||||||
|
else
|
||||||
|
:cancel ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
BDS.Desktop.Dialogs.alert(
|
||||||
|
dgettext("ui", "Connect to Server"),
|
||||||
|
dgettext("ui", "Connection failed: %{reason}",
|
||||||
|
reason: BDS.Desktop.RemoteConnection.format_reason(reason)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, menu}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("disconnect_server", menu) do
|
||||||
|
:ok = BDS.Desktop.RemoteConnection.disconnect()
|
||||||
|
Window.show(BDS.Desktop.MainWindow.window_id(), BDS.Desktop.url())
|
||||||
|
{:noreply, menu}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("open_data_folder", menu) do
|
def handle_event("open_data_folder", menu) do
|
||||||
_ = BDS.Desktop.ShellCommands.execute("open_data_folder")
|
_ = BDS.Desktop.ShellCommands.execute("open_data_folder")
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
@@ -82,6 +106,24 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("minimize", menu) do
|
||||||
|
with_frame(&:wxTopLevelWindow.iconize(&1, iconize: true))
|
||||||
|
{:noreply, menu}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("zoom", menu) do
|
||||||
|
with_frame(fn frame ->
|
||||||
|
:wxTopLevelWindow.maximize(frame, maximize: not :wxTopLevelWindow.isMaximized(frame))
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:noreply, menu}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("bring_all_to_front", menu) do
|
||||||
|
with_frame(&:wxWindow.raise/1)
|
||||||
|
{:noreply, menu}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event(command, menu) do
|
def handle_event(command, menu) do
|
||||||
dispatch_shell_menu_action(command)
|
dispatch_shell_menu_action(command)
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
@@ -91,12 +133,7 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
def handle_info({:set_ui_locale, locale}, menu) do
|
def handle_info({:set_ui_locale, locale}, menu) do
|
||||||
UILocale.put(locale)
|
UILocale.put(locale)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply, Desktop.Menu.assign(menu, :groups, groups(native_menu_opts()))}
|
||||||
Desktop.Menu.assign(
|
|
||||||
menu,
|
|
||||||
:groups,
|
|
||||||
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
|
|
||||||
)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info(_, menu) do
|
def handle_info(_, menu) do
|
||||||
@@ -123,6 +160,32 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp native_menu_opts do
|
||||||
|
[
|
||||||
|
dev_mode?: Application.get_env(:bds, :dev_routes, false),
|
||||||
|
window_menu?: OS.type() == MacOS
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp with_frame(fun) do
|
||||||
|
case frame() do
|
||||||
|
nil ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
frame ->
|
||||||
|
:wx.set_env(Desktop.Env.wx_env())
|
||||||
|
fun.(frame)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp frame do
|
||||||
|
try do
|
||||||
|
Window.frame(BDS.Desktop.MainWindow.window_id())
|
||||||
|
catch
|
||||||
|
:exit, _ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_item(%{separator: true}), do: %{separator: true}
|
defp normalize_item(%{separator: true}), do: %{separator: true}
|
||||||
|
|
||||||
defp normalize_item(item) do
|
defp normalize_item(item) do
|
||||||
@@ -145,12 +208,15 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
defp group_label(:edit), do: dgettext("ui", "Edit")
|
defp group_label(:edit), do: dgettext("ui", "Edit")
|
||||||
defp group_label(:view), do: dgettext("ui", "View")
|
defp group_label(:view), do: dgettext("ui", "View")
|
||||||
defp group_label(:blog), do: dgettext("ui", "Blog")
|
defp group_label(:blog), do: dgettext("ui", "Blog")
|
||||||
|
defp group_label(:window), do: dgettext("ui", "Window")
|
||||||
defp group_label(:help), do: dgettext("ui", "Help")
|
defp group_label(:help), do: dgettext("ui", "Help")
|
||||||
|
|
||||||
defp item_label(:new_post), do: dgettext("ui", "New Post")
|
defp item_label(:new_post), do: dgettext("ui", "New Post")
|
||||||
defp item_label(:import_media), do: dgettext("ui", "Import Media")
|
defp item_label(:import_media), do: dgettext("ui", "Import Media")
|
||||||
defp item_label(:save), do: dgettext("ui", "Save")
|
defp item_label(:save), do: dgettext("ui", "Save")
|
||||||
defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
|
defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
|
||||||
|
defp item_label(:connect_server), do: dgettext("ui", "Connect to Server…")
|
||||||
|
defp item_label(:disconnect_server), do: dgettext("ui", "Disconnect from Server")
|
||||||
defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
|
defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
|
||||||
defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
|
defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
|
||||||
defp item_label(:quit), do: dgettext("ui", "Quit")
|
defp item_label(:quit), do: dgettext("ui", "Quit")
|
||||||
@@ -188,8 +254,12 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations")
|
defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations")
|
||||||
defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts")
|
defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts")
|
||||||
defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site")
|
defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site")
|
||||||
|
defp item_label(:force_render_site), do: dgettext("ui", "Force Render Site")
|
||||||
defp item_label(:validate_site), do: dgettext("ui", "Validate Site")
|
defp item_label(:validate_site), do: dgettext("ui", "Validate Site")
|
||||||
defp item_label(:upload_site), do: dgettext("ui", "Upload Site")
|
defp item_label(:upload_site), do: dgettext("ui", "Upload Site")
|
||||||
|
defp item_label(:minimize), do: dgettext("ui", "Minimize")
|
||||||
|
defp item_label(:zoom), do: dgettext("ui", "Zoom")
|
||||||
|
defp item_label(:bring_all_to_front), do: dgettext("ui", "Bring All to Front")
|
||||||
defp item_label(:about), do: dgettext("ui", "About")
|
defp item_label(:about), do: dgettext("ui", "About")
|
||||||
defp item_label(:documentation), do: dgettext("ui", "Documentation")
|
defp item_label(:documentation), do: dgettext("ui", "Documentation")
|
||||||
defp item_label(:api_documentation), do: dgettext("ui", "API Documentation")
|
defp item_label(:api_documentation), do: dgettext("ui", "API Documentation")
|
||||||
|
|||||||
180
lib/bds/desktop/remote_connection.ex
Normal file
180
lib/bds/desktop/remote_connection.ex
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
defmodule BDS.Desktop.RemoteConnection do
|
||||||
|
@moduledoc """
|
||||||
|
GUI remote mode (issue #26, phase 5).
|
||||||
|
|
||||||
|
Connects the desktop app to a headless bDS2 server over the same SSH
|
||||||
|
channel (and the same public keys) the TUI uses: `:ssh.connect` with
|
||||||
|
public-key auth, then an OTP TCP/IP tunnel to the server's loopback HTTP
|
||||||
|
endpoint. The webview then loads the local tunnel end — the server never
|
||||||
|
exposes HTTP.
|
||||||
|
|
||||||
|
Client key material lives in the same private `ssh/` directory next to
|
||||||
|
the database (`BDS.Server.ssh_dir/0`): an `id_ed25519`/`id_rsa` identity
|
||||||
|
and a `known_hosts` written on first connect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@default_ssh_port 2222
|
||||||
|
@tunnel_timeout_ms 10_000
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
case Keyword.pop(opts, :name, __MODULE__) do
|
||||||
|
{nil, init_opts} -> GenServer.start_link(__MODULE__, init_opts)
|
||||||
|
{name, init_opts} -> GenServer.start_link(__MODULE__, init_opts, name: name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses `user@host[:port]` into connection parameters. The port is the
|
||||||
|
server's SSH port (default #{@default_ssh_port}).
|
||||||
|
"""
|
||||||
|
@spec parse_target(String.t()) ::
|
||||||
|
{:ok, %{user: String.t(), host: String.t(), port: pos_integer()}}
|
||||||
|
| {:error, :invalid_target}
|
||||||
|
def parse_target(target) when is_binary(target) do
|
||||||
|
with [user, rest] when user != "" <- String.split(String.trim(target), "@", parts: 2),
|
||||||
|
{:ok, host, port} <- parse_host_port(rest) do
|
||||||
|
{:ok, %{user: user, host: host, port: port}}
|
||||||
|
else
|
||||||
|
_other -> {:error, :invalid_target}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_host_port(rest) do
|
||||||
|
case String.split(rest, ":", parts: 2) do
|
||||||
|
[host] when host != "" ->
|
||||||
|
{:ok, host, @default_ssh_port}
|
||||||
|
|
||||||
|
[host, port] when host != "" ->
|
||||||
|
case Integer.parse(port) do
|
||||||
|
{number, ""} when number > 0 -> {:ok, host, number}
|
||||||
|
_other -> :error
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
:error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Connects and tunnels; returns the local URL the webview should load."
|
||||||
|
@spec connect(GenServer.server(), String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||||
|
def connect(server \\ __MODULE__, target) do
|
||||||
|
GenServer.call(server, {:connect, target}, @tunnel_timeout_ms + 5_000)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec disconnect(GenServer.server()) :: :ok
|
||||||
|
def disconnect(server \\ __MODULE__) do
|
||||||
|
GenServer.call(server, :disconnect)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec status(GenServer.server()) :: :disconnected | {:connected, String.t(), String.t()}
|
||||||
|
def status(server \\ __MODULE__) do
|
||||||
|
GenServer.call(server, :status)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Human-readable connection error for menu/shell error reporting."
|
||||||
|
@spec format_reason(term()) :: String.t()
|
||||||
|
def format_reason(:invalid_target) do
|
||||||
|
dgettext("ui", "Use the form user@host or user@host:port.")
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_reason(%{message: message}) when is_binary(message), do: message
|
||||||
|
def format_reason(reason), do: inspect(reason)
|
||||||
|
|
||||||
|
## GenServer callbacks
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts) do
|
||||||
|
overrides = Keyword.get(opts, :test_overrides, [])
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
conn: nil,
|
||||||
|
monitor: nil,
|
||||||
|
target: nil,
|
||||||
|
url: nil,
|
||||||
|
connect_fun: Keyword.get(overrides, :connect_fun, &:ssh.connect/3),
|
||||||
|
tunnel_fun: Keyword.get(overrides, :tunnel_fun, &:ssh.tcpip_tunnel_to_server/6),
|
||||||
|
close_fun: Keyword.get(overrides, :close_fun, &:ssh.close/1),
|
||||||
|
ssh_dir_fun: Keyword.get(overrides, :ssh_dir_fun, &BDS.Server.ssh_dir/0)
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:connect, target}, _from, state) do
|
||||||
|
state = close_current(state)
|
||||||
|
|
||||||
|
with {:ok, parsed} <- parse_target(target),
|
||||||
|
{:ok, conn} <- do_connect(state, parsed),
|
||||||
|
{:ok, local_port} <- do_tunnel(state, conn) do
|
||||||
|
url = "http://127.0.0.1:#{local_port}/"
|
||||||
|
|
||||||
|
{:reply, {:ok, url},
|
||||||
|
%{
|
||||||
|
state
|
||||||
|
| conn: conn,
|
||||||
|
monitor: Process.monitor(conn),
|
||||||
|
target: String.trim(target),
|
||||||
|
url: url
|
||||||
|
}}
|
||||||
|
else
|
||||||
|
{:error, reason} = error ->
|
||||||
|
Logger.warning("Remote connection to #{inspect(target)} failed: #{inspect(reason)}")
|
||||||
|
{:reply, error, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:disconnect, _from, state) do
|
||||||
|
{:reply, :ok, close_current(state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:status, _from, %{conn: nil} = state) do
|
||||||
|
{:reply, :disconnected, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:status, _from, state) do
|
||||||
|
{:reply, {:connected, state.target, state.url}, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:DOWN, monitor, :process, _pid, reason}, %{monitor: monitor} = state) do
|
||||||
|
Logger.warning("Remote SSH connection lost: #{inspect(reason)}")
|
||||||
|
{:noreply, %{state | conn: nil, monitor: nil, target: nil, url: nil}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(_message, state), do: {:noreply, state}
|
||||||
|
|
||||||
|
## Internal helpers
|
||||||
|
|
||||||
|
defp do_connect(state, %{user: user, host: host, port: port}) do
|
||||||
|
ssh_dir = BDS.Server.ensure_ssh_dir!(state.ssh_dir_fun.())
|
||||||
|
|
||||||
|
state.connect_fun.(String.to_charlist(host), port,
|
||||||
|
user: String.to_charlist(user),
|
||||||
|
user_dir: String.to_charlist(ssh_dir),
|
||||||
|
auth_methods: ~c"publickey",
|
||||||
|
# First connect records the host key in <ssh_dir>/known_hosts;
|
||||||
|
# later connects verify against it (trust-on-first-use).
|
||||||
|
silently_accept_hosts: true,
|
||||||
|
connect_timeout: @tunnel_timeout_ms
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_tunnel(state, conn) do
|
||||||
|
remote_port = Application.get_env(:bds, :desktop)[:port] || 4010
|
||||||
|
|
||||||
|
state.tunnel_fun.(conn, ~c"127.0.0.1", 0, ~c"127.0.0.1", remote_port, @tunnel_timeout_ms)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp close_current(%{conn: nil} = state), do: state
|
||||||
|
|
||||||
|
defp close_current(state) do
|
||||||
|
if state.monitor, do: Process.demonitor(state.monitor, [:flush])
|
||||||
|
_ = state.close_fun.(state.conn)
|
||||||
|
%{state | conn: nil, monitor: nil, target: nil, url: nil}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -19,6 +19,7 @@ defmodule BDS.Desktop.Router do
|
|||||||
|
|
||||||
get("/health", HealthController, :show)
|
get("/health", HealthController, :show)
|
||||||
get("/media-thumbnail/:media_id", MediaController, :thumbnail)
|
get("/media-thumbnail/:media_id", MediaController, :thumbnail)
|
||||||
|
get("/media-file/:media_id", MediaController, :file)
|
||||||
|
|
||||||
live_session :desktop_shell,
|
live_session :desktop_shell,
|
||||||
root_layout: {BDS.Desktop.Layouts, :root} do
|
root_layout: {BDS.Desktop.Layouts, :root} do
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
"rebuild_embedding_index",
|
"rebuild_embedding_index",
|
||||||
"Rebuild Embedding Index",
|
"Rebuild Embedding Index",
|
||||||
"Embeddings",
|
"Embeddings",
|
||||||
fn report -> rebuild_embedding_index_work(project, report) end
|
fn report -> Maintenance.rebuild_embedding_index(project.id, on_progress: report) end
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -196,6 +196,14 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp dispatch("rebuild_media_links", project, _params) do
|
||||||
|
queue_task(project, "rebuild_media_links", "Rebuild Media Links", "Maintenance", fn report ->
|
||||||
|
{:ok, result} = BDS.Media.rebuild_media_links(project.id, on_progress: report)
|
||||||
|
report.(1.0, "Media links rebuilt")
|
||||||
|
%{project_id: project.id, counts: %{media_links: result.links}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp dispatch("regenerate_missing_thumbnails", project, _params) do
|
defp dispatch("regenerate_missing_thumbnails", project, _params) do
|
||||||
queue_task(
|
queue_task(
|
||||||
project,
|
project,
|
||||||
@@ -213,7 +221,7 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
defp dispatch("rebuild_database", project, _params) do
|
defp dispatch("rebuild_database", project, _params) do
|
||||||
group_id = task_group_id("rebuild_database")
|
group_id = task_group_id("rebuild_database")
|
||||||
attrs = %{group_id: group_id, group_name: "Maintenance"}
|
attrs = %{group_id: group_id, group_name: "Maintenance"}
|
||||||
[first_step | remaining_steps] = rebuild_database_steps(project)
|
[first_step | remaining_steps] = Maintenance.full_rebuild_steps(project.id)
|
||||||
|
|
||||||
{:ok, posts_task} =
|
{:ok, posts_task} =
|
||||||
Tasks.submit_task(first_step.name, first_step.work, attrs)
|
Tasks.submit_task(first_step.name, first_step.work, attrs)
|
||||||
@@ -239,18 +247,13 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("generate_sitemap", project, _params) do
|
defp dispatch("generate_sitemap", project, _params) do
|
||||||
queue_task(project, "generate_sitemap", "Generate Site", "Generation", fn report ->
|
queue_render_site(project, "generate_sitemap", "Render Site", [])
|
||||||
{:ok, generation} =
|
end
|
||||||
Generation.generate_site(project.id, @site_sections, on_progress: report)
|
|
||||||
|
|
||||||
report.(1.0, "Generated site output")
|
# Forced variant of the site render: ignores the stored content hashes and
|
||||||
|
# rewrites every output file, repairing any drift on disk.
|
||||||
%{
|
defp dispatch("force_render_site", project, _params) do
|
||||||
project_id: project.id,
|
queue_render_site(project, "force_render_site", "Force Render Site", force: true)
|
||||||
sections: generation.sections,
|
|
||||||
generated_count: length(generation.generated_files)
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("validate_site", project, _params) do
|
defp dispatch("validate_site", project, _params) do
|
||||||
@@ -262,6 +265,42 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp dispatch("apply_site_validation", project, params) do
|
||||||
|
validation_report = normalize_apply_validation_report(BDS.MapUtils.attr(params, :report, %{}))
|
||||||
|
group_id = task_group_id("apply_site_validation")
|
||||||
|
attrs = %{group_id: group_id, group_name: "Apply Site Validation"}
|
||||||
|
|
||||||
|
{:ok, prepare_task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
"Prepare Validation Apply",
|
||||||
|
fn report ->
|
||||||
|
{:ok, preparation} =
|
||||||
|
Generation.prepare_validation_apply(project.id, validation_report,
|
||||||
|
on_progress: report
|
||||||
|
)
|
||||||
|
|
||||||
|
Map.put(preparation, :project_id, project.id)
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.start(fn ->
|
||||||
|
run_apply_validation_sequence(project, validation_report, group_id, attrs, prepare_task.id)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
kind: "task_queued",
|
||||||
|
action: "apply_site_validation",
|
||||||
|
title: "Apply Site Validation",
|
||||||
|
message: "Apply Site Validation tasks queued",
|
||||||
|
project_id: project.id,
|
||||||
|
task_id: prepare_task.id,
|
||||||
|
task_group_id: group_id,
|
||||||
|
panel_tab: "tasks"
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
defp dispatch("metadata_diff", project, _params) do
|
defp dispatch("metadata_diff", project, _params) do
|
||||||
queue_task(project, "metadata_diff", "Metadata Diff", "Maintenance", fn report ->
|
queue_task(project, "metadata_diff", "Metadata Diff", "Maintenance", fn report ->
|
||||||
{:ok, metadata_diff} = Maintenance.metadata_diff(project.id, on_progress: report)
|
{:ok, metadata_diff} = Maintenance.metadata_diff(project.id, on_progress: report)
|
||||||
@@ -439,6 +478,207 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp queue_render_site(project, action, title, render_opts) do
|
||||||
|
group_id = task_group_id(action)
|
||||||
|
attrs = %{group_id: group_id, group_name: title}
|
||||||
|
|
||||||
|
{:ok, first_task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
section_task_name(:core),
|
||||||
|
fn report ->
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.render_site_section(
|
||||||
|
project.id,
|
||||||
|
:core,
|
||||||
|
[on_progress: report] ++ render_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
report.(1.0, section_complete_message(:core))
|
||||||
|
:ok
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.start(fn -> run_render_site_sequence(project, group_id, attrs, render_opts) end)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
kind: "task_queued",
|
||||||
|
action: action,
|
||||||
|
title: title,
|
||||||
|
message: title <> " tasks queued",
|
||||||
|
project_id: project.id,
|
||||||
|
task_id: first_task.id,
|
||||||
|
task_group_id: group_id,
|
||||||
|
panel_tab: "tasks"
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_render_site_sequence(project, group_id, attrs, render_opts) do
|
||||||
|
remaining_sections = [:single, :category, :tag, :date]
|
||||||
|
|
||||||
|
Enum.each(remaining_sections, fn section ->
|
||||||
|
{:ok, _task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
section_task_name(section),
|
||||||
|
fn report ->
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.render_site_section(
|
||||||
|
project.id,
|
||||||
|
section,
|
||||||
|
[on_progress: report] ++ render_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
report.(1.0, section_complete_message(section))
|
||||||
|
:ok
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
section_names = Enum.map([:core | remaining_sections], §ion_task_name/1)
|
||||||
|
|
||||||
|
with :ok <- wait_for_group_phase(group_id, section_names, @rebuild_phase_timeout) do
|
||||||
|
{:ok, _task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
"Build Search Index",
|
||||||
|
fn report ->
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.build_site_search_index(
|
||||||
|
project.id,
|
||||||
|
[on_progress: report] ++ render_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
report.(1.0, "Build Search Index complete")
|
||||||
|
:ok
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
wait_for_group_phase(group_id, ["Build Search Index"], @rebuild_phase_timeout)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_apply_validation_sequence(project, validation_report, group_id, attrs, prepare_task_id) do
|
||||||
|
with :ok <-
|
||||||
|
wait_for_group_phase(group_id, ["Prepare Validation Apply"], @rebuild_phase_timeout),
|
||||||
|
{:ok, preparation} <- completed_task_result(prepare_task_id),
|
||||||
|
:ok <-
|
||||||
|
run_apply_validation_render_tasks(
|
||||||
|
project,
|
||||||
|
validation_report,
|
||||||
|
group_id,
|
||||||
|
attrs,
|
||||||
|
preparation
|
||||||
|
) do
|
||||||
|
if validation_apply_changed?(preparation) do
|
||||||
|
:ok = run_apply_validation_refresh_task(project, group_id, attrs, :calendar)
|
||||||
|
end
|
||||||
|
|
||||||
|
submit_apply_validation_check_task(project, attrs)
|
||||||
|
else
|
||||||
|
_other -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_apply_validation_render_tasks(project, validation_report, group_id, attrs, preparation) do
|
||||||
|
sections = Map.get(preparation, :sections_to_render, [])
|
||||||
|
task_names = Enum.map(sections, §ion_task_name/1)
|
||||||
|
|
||||||
|
Enum.each(sections, fn section ->
|
||||||
|
task_name = section_task_name(section)
|
||||||
|
|
||||||
|
{:ok, _task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
task_name,
|
||||||
|
fn report ->
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.apply_validation_section(project.id, validation_report, section,
|
||||||
|
on_progress: report
|
||||||
|
)
|
||||||
|
|
||||||
|
report.(1.0, section_complete_message(section))
|
||||||
|
:ok
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
case task_names do
|
||||||
|
[] -> :ok
|
||||||
|
names -> wait_for_group_phase(group_id, names, @rebuild_phase_timeout)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_apply_validation_refresh_task(project, group_id, attrs, kind) do
|
||||||
|
task_name = apply_validation_refresh_task_name(kind)
|
||||||
|
|
||||||
|
{:ok, _task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
task_name,
|
||||||
|
fn report ->
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.refresh_validation_ancillary_outputs(project.id, kind, on_progress: report)
|
||||||
|
|
||||||
|
report.(1.0, apply_validation_refresh_complete_message(kind))
|
||||||
|
:ok
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
wait_for_group_phase(group_id, [task_name], @rebuild_phase_timeout)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp submit_apply_validation_check_task(project, attrs) do
|
||||||
|
{:ok, _task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
"Validate Site",
|
||||||
|
fn report ->
|
||||||
|
{:ok, validation} =
|
||||||
|
Generation.validate_site(project.id, @site_sections, on_progress: report)
|
||||||
|
|
||||||
|
site_validation_result(project.id, validation)
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp completed_task_result(task_id) do
|
||||||
|
case Tasks.get_task(task_id) do
|
||||||
|
%{status: :completed, result: result} when is_map(result) -> {:ok, result}
|
||||||
|
%{status: :failed, error: error} -> {:error, error}
|
||||||
|
_other -> {:error, :task_result_unavailable}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validation_apply_changed?(preparation) do
|
||||||
|
Map.get(preparation, :sections_to_render, []) != [] or
|
||||||
|
Map.get(preparation, :deleted_url_count, 0) > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
defp section_task_name(:core), do: "Render Site Core"
|
||||||
|
defp section_task_name(:single), do: "Render Single Posts"
|
||||||
|
defp section_task_name(:category), do: "Render Category Archives"
|
||||||
|
defp section_task_name(:tag), do: "Render Tag Archives"
|
||||||
|
defp section_task_name(:date), do: "Render Date Archives"
|
||||||
|
|
||||||
|
defp section_complete_message(:core), do: "Render Site Core complete"
|
||||||
|
defp section_complete_message(:single), do: "Render Single Posts complete"
|
||||||
|
|
||||||
|
defp section_complete_message(:category),
|
||||||
|
do: "Render Category Archives complete"
|
||||||
|
|
||||||
|
defp section_complete_message(:tag), do: "Render Tag Archives complete"
|
||||||
|
defp section_complete_message(:date), do: "Render Date Archives complete"
|
||||||
|
|
||||||
|
defp apply_validation_refresh_task_name(:calendar), do: "Regenerate Calendar"
|
||||||
|
|
||||||
|
defp apply_validation_refresh_complete_message(:calendar), do: "Regenerate Calendar complete"
|
||||||
|
|
||||||
defp translation_fill_enabled?(metadata) do
|
defp translation_fill_enabled?(metadata) do
|
||||||
([metadata.main_language] ++ metadata.blog_languages)
|
([metadata.main_language] ++ metadata.blog_languages)
|
||||||
|> Enum.map(fn language ->
|
|> Enum.map(fn language ->
|
||||||
@@ -452,102 +692,6 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
|> length() > 1
|
|> length() > 1
|
||||||
end
|
end
|
||||||
|
|
||||||
defp rebuild_database_steps(project) do
|
|
||||||
[
|
|
||||||
%{
|
|
||||||
name: "Rebuild Posts From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, posts} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "post",
|
|
||||||
on_progress: report,
|
|
||||||
rebuild_embeddings: false
|
|
||||||
)
|
|
||||||
|
|
||||||
report.(1.0, "Post rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{posts: length(posts)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Media From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, media} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
|
|
||||||
|
|
||||||
report.(1.0, "Media rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{media: length(media)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Scripts From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, scripts} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
|
|
||||||
|
|
||||||
report.(1.0, "Script rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{scripts: length(scripts)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Templates From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, templates} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
|
|
||||||
|
|
||||||
report.(1.0, "Template rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{templates: length(templates)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Post Links",
|
|
||||||
work: fn report ->
|
|
||||||
:ok = Posts.rebuild_post_links(project.id, on_progress: report)
|
|
||||||
report.(1.0, "Post links rebuilt")
|
|
||||||
%{project_id: project.id}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Regenerate Missing Thumbnails",
|
|
||||||
work: fn report ->
|
|
||||||
result = BDS.Media.regenerate_missing_thumbnails(project.id, on_progress: report)
|
|
||||||
report.(1.0, "Missing thumbnails regenerated")
|
|
||||||
Map.put(result, :project_id, project.id)
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Embedding Index",
|
|
||||||
work: fn report -> rebuild_embedding_index_work(project, report) end
|
|
||||||
}
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
defp rebuild_embedding_index_work(project, report) do
|
|
||||||
case Embeddings.rebuild_project(project.id, on_progress: report) do
|
|
||||||
{:ok, rebuilt_post_ids} ->
|
|
||||||
report.(1.0, "Embedding index rebuilt")
|
|
||||||
|
|
||||||
%{
|
|
||||||
project_id: project.id,
|
|
||||||
rebuilt_post_ids: rebuilt_post_ids,
|
|
||||||
rebuilt_count: length(rebuilt_post_ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
{:error, embedding_error_message(reason)}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp embedding_error_message(reason) do
|
|
||||||
detail =
|
|
||||||
case reason do
|
|
||||||
message when is_binary(message) -> message
|
|
||||||
{:embedding_backend_unavailable, _inner} -> "the embedding service did not start"
|
|
||||||
other -> inspect(other)
|
|
||||||
end
|
|
||||||
|
|
||||||
"Could not build the embedding index: #{detail}. The model is downloaded on first use, " <>
|
|
||||||
"so check your internet connection — or turn off semantic similarity in Settings."
|
|
||||||
end
|
|
||||||
|
|
||||||
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
|
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
|
||||||
|
|
||||||
defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do
|
defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do
|
||||||
@@ -640,6 +784,20 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
"http://#{server.host}:#{server.port}/"
|
"http://#{server.host}:#{server.port}/"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp normalize_apply_validation_report(report) when is_map(report) do
|
||||||
|
%{
|
||||||
|
sitemap_path: BDS.MapUtils.attr(report, :sitemap_path),
|
||||||
|
sitemap_changed: BDS.MapUtils.attr(report, :sitemap_changed, false),
|
||||||
|
missing_url_paths: BDS.MapUtils.attr(report, :missing_url_paths, []),
|
||||||
|
extra_url_paths: BDS.MapUtils.attr(report, :extra_url_paths, []),
|
||||||
|
updated_post_url_paths: BDS.MapUtils.attr(report, :updated_post_url_paths, []),
|
||||||
|
expected_url_count: BDS.MapUtils.attr(report, :expected_url_count, 0),
|
||||||
|
existing_html_url_count: BDS.MapUtils.attr(report, :existing_html_url_count, 0)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_apply_validation_report(_report), do: %{}
|
||||||
|
|
||||||
defp normalize_site_validation(report) do
|
defp normalize_site_validation(report) do
|
||||||
%{
|
%{
|
||||||
sitemap_path: report.sitemap_path,
|
sitemap_path: report.sitemap_path,
|
||||||
|
|||||||
@@ -91,32 +91,16 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def assistant_cards do
|
# Accepts the caller's already-resolved locale so hot render paths never
|
||||||
[
|
# re-read the ui.language setting from the database.
|
||||||
%{
|
def editor_meta(task_status, ui_language \\ nil) do
|
||||||
label: dgettext("ui", "Offline Gate"),
|
|
||||||
text: dgettext("ui", "Automatic AI actions stay gated by airplane mode.")
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
label: dgettext("ui", "Filesystem Sync"),
|
|
||||||
text:
|
|
||||||
dgettext("ui", "Metadata flush, diffing, and rebuild hooks still need editor wiring.")
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
label: dgettext("ui", "Desktop Runtime"),
|
|
||||||
text: dgettext("ui", "The app window is now served from LiveView state.")
|
|
||||||
}
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
def editor_meta(task_status) do
|
|
||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
label: dgettext("ui", "Status"),
|
label: dgettext("ui", "Status"),
|
||||||
value: task_status.running_task_message || dgettext("ui", "Idle")
|
value: task_status.running_task_message || dgettext("ui", "Idle")
|
||||||
},
|
},
|
||||||
%{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")},
|
%{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")},
|
||||||
%{label: dgettext("ui", "Main Language"), value: ui_language()}
|
%{label: dgettext("ui", "Main Language"), value: ui_language || ui_language()}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -125,7 +109,7 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
post_count: dashboard.post_stats.total_posts,
|
post_count: dashboard.post_stats.total_posts,
|
||||||
media_count: dashboard.media_stats.media_count,
|
media_count: dashboard.media_stats.media_count,
|
||||||
theme_badge: "desktop-shell",
|
theme_badge: "desktop-shell",
|
||||||
ui_language: Keyword.get(opts, :ui_language, ui_language()),
|
ui_language: Keyword.get_lazy(opts, :ui_language, &ui_language/0),
|
||||||
offline_mode: Keyword.get(opts, :offline_mode, true),
|
offline_mode: Keyword.get(opts, :offline_mode, true),
|
||||||
running_task_message: task_status.running_task_message,
|
running_task_message: task_status.running_task_message,
|
||||||
running_task_overflow: task_status.running_task_overflow,
|
running_task_overflow: task_status.running_task_overflow,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
import Phoenix.HTML
|
import Phoenix.HTML
|
||||||
|
|
||||||
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
|
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
|
||||||
alias BDS.CliSync.Watcher
|
|
||||||
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData, UILocale}
|
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData, UILocale}
|
||||||
|
|
||||||
alias BDS.Desktop.ShellLive.{
|
alias BDS.Desktop.ShellLive.{
|
||||||
@@ -14,6 +13,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
ChatEditor,
|
ChatEditor,
|
||||||
GalleryImport,
|
GalleryImport,
|
||||||
GitHandler,
|
GitHandler,
|
||||||
|
GitRun,
|
||||||
ImportEditor,
|
ImportEditor,
|
||||||
MediaEditor,
|
MediaEditor,
|
||||||
MenuEditor,
|
MenuEditor,
|
||||||
@@ -62,6 +62,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
@refresh_interval 1_500
|
@refresh_interval 1_500
|
||||||
|
|
||||||
def refresh_interval, do: @refresh_interval
|
def refresh_interval, do: @refresh_interval
|
||||||
|
|
||||||
@sidebar_filter_events [
|
@sidebar_filter_events [
|
||||||
"toggle_sidebar_filters",
|
"toggle_sidebar_filters",
|
||||||
"toggle_sidebar_archive",
|
"toggle_sidebar_archive",
|
||||||
@@ -107,7 +108,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
:quit,
|
:quit,
|
||||||
:view_on_github,
|
:view_on_github,
|
||||||
:report_issue,
|
:report_issue,
|
||||||
:about
|
:about,
|
||||||
|
:connect_server,
|
||||||
|
:disconnect_server
|
||||||
])
|
])
|
||||||
@runtime_menu_actions MapSet.new([
|
@runtime_menu_actions MapSet.new([
|
||||||
:undo,
|
:undo,
|
||||||
@@ -137,7 +140,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> MapSet.union(
|
|> MapSet.union(
|
||||||
MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates])
|
MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates])
|
||||||
)
|
)
|
||||||
|> MapSet.union(MapSet.new([:generate_sitemap, :validate_site, :upload_site]))
|
|> MapSet.union(
|
||||||
|
MapSet.new([:generate_sitemap, :force_render_site, :validate_site, :upload_site])
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
embed_templates("shell_live/*")
|
embed_templates("shell_live/*")
|
||||||
@@ -147,7 +152,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
connected = connected?(socket)
|
connected = connected?(socket)
|
||||||
|
|
||||||
if connected do
|
if connected do
|
||||||
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
|
# Entity + settings events from every source: in-app contexts, other
|
||||||
|
# connected clients (GUI or TUI), and the CLI sync watcher.
|
||||||
|
:ok = BDS.Events.subscribe()
|
||||||
Process.send_after(self(), :refresh_task_status, @refresh_interval)
|
Process.send_after(self(), :refresh_task_status, @refresh_interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -173,12 +180,14 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:chat_editor_request_refs, %{})
|
|> assign(:chat_editor_request_refs, %{})
|
||||||
|> assign(:file_picker_task, nil)
|
|> assign(:file_picker_task, nil)
|
||||||
|> assign(:shell_overlay, nil)
|
|> assign(:shell_overlay, nil)
|
||||||
|
|> assign(:git_run, nil)
|
||||||
|> assign(:output_entries, [])
|
|> assign(:output_entries, [])
|
||||||
|
|> assign(:collapsed_task_groups, MapSet.new())
|
||||||
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
||||||
|> assign(:panel_git_entries, [])
|
|> assign(:panel_git_entries, [])
|
||||||
|> assign(:auto_save_timers, %{})
|
|> assign(:auto_save_timers, %{})
|
||||||
|> reload_shell(workbench)
|
|> reload_shell(workbench)
|
||||||
|> UrlState.apply_params(params)
|
|> UrlState.apply_params(params)
|
||||||
|> tap(&sync_menu_bar_locale/1)}
|
|> tap(&sync_menu_bar_locale/1)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -206,7 +215,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> refresh_sidebar(workbench)
|
|> refresh_sidebar(workbench)
|
||||||
|> UrlState.push()}
|
|> UrlState.push()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
|
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
|
||||||
@@ -218,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
|
||||||
@@ -239,9 +280,25 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(event, _params, socket) when event in @git_action_events do
|
def handle_event(event, _params, socket) when event in @git_action_events do
|
||||||
{:noreply, GitHandler.run_action(socket, event)}
|
if GitRun.network_event?(event) do
|
||||||
|
{:noreply, GitRun.start(socket, event, current_project_id(socket))}
|
||||||
|
else
|
||||||
|
{:noreply, GitHandler.run_action(socket, event)}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("git_run_close", _params, socket) do
|
||||||
|
socket = GitRun.close(socket)
|
||||||
|
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("git_run_keydown", %{"key" => "Escape"}, socket) do
|
||||||
|
socket = GitRun.close(socket)
|
||||||
|
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("git_run_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
def handle_event("git_commit", params, socket) do
|
def handle_event("git_commit", params, socket) do
|
||||||
message = params |> get_in(["git", "message"]) |> to_string() |> String.trim()
|
message = params |> get_in(["git", "message"]) |> to_string() |> String.trim()
|
||||||
|
|
||||||
@@ -299,7 +356,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
socket
|
socket
|
||||||
|> assign(:tab_meta, tab_meta)
|
|> assign(:tab_meta, tab_meta)
|
||||||
|> refresh_layout(workbench)
|
|> refresh_layout(workbench)
|
||||||
|> UrlState.push()}
|
|> UrlState.push()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(
|
def handle_event(
|
||||||
@@ -524,7 +581,20 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
def handle_event("restore_workbench_session", %{"session" => session_payload}, socket)
|
def handle_event("restore_workbench_session", %{"session" => session_payload}, socket)
|
||||||
when is_map(session_payload) do
|
when is_map(session_payload) do
|
||||||
{:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))}
|
restored =
|
||||||
|
session_payload
|
||||||
|
|> SessionUtil.restore_workbench_session()
|
||||||
|
|> preserve_active_tab(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:noreply, reload_shell(socket, restored)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# The client signals readiness after mounting its hooks and pushing any
|
||||||
|
# stored workbench session. Deep links attach only now, so cold-start
|
||||||
|
# blogmark links run after rehydration and the editor they open survives.
|
||||||
|
def handle_event("shell_ready", _params, socket) do
|
||||||
|
attach_deep_link()
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(event, params, socket) when event in @native_menu_events do
|
def handle_event(event, params, socket) when event in @native_menu_events do
|
||||||
@@ -688,6 +758,28 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply, handle_blogmark_deep_link(socket, url)}
|
{:noreply, handle_blogmark_deep_link(socket, url)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({:git_output, ref, stream, chunk}, socket) do
|
||||||
|
{:noreply, GitRun.append(socket, ref, stream, chunk)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:git_done, ref, status}, socket) do
|
||||||
|
socket = GitRun.done(socket, ref, status)
|
||||||
|
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:shell_alert, title, message}, socket) do
|
||||||
|
overlay = %{kind: :alert, title: title, message: message, tone: "error"}
|
||||||
|
{:noreply, assign(socket, :shell_overlay, overlay)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# The UI language is a server-side setting shared by all clients; when any
|
||||||
|
# client (or script) changes it, every shell re-renders in the new locale.
|
||||||
|
def handle_info({:settings_changed, "ui.language"}, socket) do
|
||||||
|
{:noreply, set_page_language(socket, ShellData.ui_language())}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:settings_changed, _key}, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
def handle_info(message, socket) do
|
def handle_info(message, socket) do
|
||||||
Bridges.handle_info(message, socket, bridges_callbacks())
|
Bridges.handle_info(message, socket, bridges_callbacks())
|
||||||
end
|
end
|
||||||
@@ -698,6 +790,20 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
index(assigns)
|
index(assigns)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Dark-themed toast classes for LiveToast (its default is bg-white/text-black).
|
||||||
|
# Mirrors the library's structural classes but uses the app's dark palette.
|
||||||
|
@doc false
|
||||||
|
@spec toast_class_fn(map()) :: [String.t() | false]
|
||||||
|
def toast_class_fn(assigns) do
|
||||||
|
[
|
||||||
|
"group/toast z-100 pointer-events-auto relative w-full items-center justify-between origin-center overflow-hidden rounded-lg p-4 shadow-lg border col-start-1 col-end-1 row-start-1 row-end-2",
|
||||||
|
"bg-[#2d2d2d] text-[#f0f0f0] border-[#3c3c3c]",
|
||||||
|
"[@media(scripting:enabled)]:opacity-0 [@media(scripting:enabled){[data-phx-main]_&}]:opacity-100",
|
||||||
|
if(assigns[:rest][:hidden] == true, do: "hidden", else: "flex"),
|
||||||
|
assigns[:kind] == :error && "!bg-[#3a2020] !text-red-300 !border-[#5a2a2a]"
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
defp refresh_layout(socket, workbench), do: SocketState.refresh_layout(socket, workbench)
|
defp refresh_layout(socket, workbench), do: SocketState.refresh_layout(socket, workbench)
|
||||||
|
|
||||||
defp refresh_sidebar(socket, workbench), do: SocketState.refresh_sidebar(socket, workbench)
|
defp refresh_sidebar(socket, workbench), do: SocketState.refresh_sidebar(socket, workbench)
|
||||||
@@ -737,30 +843,45 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
# create a draft post, open it in the editor, and surface transform toasts.
|
# create a draft post, open it in the editor, and surface transform toasts.
|
||||||
defp handle_blogmark_deep_link(socket, url) do
|
defp handle_blogmark_deep_link(socket, url) do
|
||||||
title = dgettext("ui", "Blogmark")
|
title = dgettext("ui", "Blogmark")
|
||||||
|
link_project_id = Blogmark.deep_link_project_id(url)
|
||||||
|
active = current_project_id(socket)
|
||||||
|
|
||||||
case current_project_id(socket) do
|
cond do
|
||||||
project_id when is_binary(project_id) ->
|
# A specific project was requested but does not exist in this install
|
||||||
case Blogmark.receive_deep_link(project_id, url) do
|
# (e.g. a bookmarklet copied from another instance). Never guess another
|
||||||
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
|
# project — tell the user, import nothing.
|
||||||
|
is_binary(link_project_id) and Projects.get_project(link_project_id) == nil ->
|
||||||
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
title,
|
||||||
|
dgettext("ui", "The project this blogmark targets does not exist here."),
|
||||||
|
url,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
|
||||||
|
# A specific, existing project that isn't active: switch to it first so
|
||||||
|
# the new post is shown in context, then import there.
|
||||||
|
is_binary(link_project_id) and link_project_id != active ->
|
||||||
|
case Projects.set_active_project(link_project_id) do
|
||||||
|
{:ok, _project} ->
|
||||||
socket
|
socket
|
||||||
|> reload_shell(socket.assigns.workbench)
|
|> assign(:project_menu_open, false)
|
||||||
|> open_sidebar_item(
|
|> assign(:sidebar_filters_by_view, %{})
|
||||||
%{
|
|> reload_shell(Workbench.new())
|
||||||
"route" => "post",
|
|> UrlState.push()
|
||||||
"id" => post.id,
|
|> import_blogmark(link_project_id, url, title)
|
||||||
"title" => post.title,
|
|
||||||
"subtitle" => post.slug
|
|
||||||
},
|
|
||||||
:pin
|
|
||||||
)
|
|
||||||
|> append_blogmark_toasts(title, toasts)
|
|
||||||
|> append_blogmark_errors(title, errors)
|
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
append_output_entry(socket, title, inspect(reason), url, "error")
|
append_output_entry(socket, title, inspect(reason), url, "error")
|
||||||
end
|
end
|
||||||
|
|
||||||
_ ->
|
# The link names the already-active project, or no project_id was given
|
||||||
|
# and a project is active: import into the active project.
|
||||||
|
is_binary(active) ->
|
||||||
|
import_blogmark(socket, active, url, title)
|
||||||
|
|
||||||
|
# No project_id and nothing active.
|
||||||
|
true ->
|
||||||
append_output_entry(
|
append_output_entry(
|
||||||
socket,
|
socket,
|
||||||
title,
|
title,
|
||||||
@@ -771,12 +892,67 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp import_blogmark(socket, project_id, url, title) do
|
||||||
|
# Transform scripts run in the sandbox process; collect anything they
|
||||||
|
# print/log so it lands in the Output panel like in the old app.
|
||||||
|
{:ok, output_sink} = Agent.start_link(fn -> [] end)
|
||||||
|
|
||||||
|
sink = fn text -> Agent.update(output_sink, fn lines -> [text | lines] end) end
|
||||||
|
|
||||||
|
try do
|
||||||
|
case Blogmark.receive_deep_link(project_id, url, on_output: sink) do
|
||||||
|
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
|
||||||
|
script_output = Agent.get(output_sink, &Enum.reverse/1)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> reload_shell(socket.assigns.workbench)
|
||||||
|
|> open_sidebar_item(
|
||||||
|
%{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => post.slug
|
||||||
|
},
|
||||||
|
:pin
|
||||||
|
)
|
||||||
|
|> append_blogmark_script_output(title, script_output)
|
||||||
|
|> append_blogmark_toasts(title, toasts)
|
||||||
|
|> append_blogmark_errors(title, errors)
|
||||||
|
|> open_output_panel_when(script_output != [] or errors != [])
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
append_output_entry(socket, title, inspect(reason), url, "error")
|
||||||
|
end
|
||||||
|
after
|
||||||
|
Agent.stop(output_sink)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp append_blogmark_script_output(socket, title, lines) do
|
||||||
|
Enum.reduce(lines, socket, fn line, acc ->
|
||||||
|
append_output_entry(acc, title, line, nil, "info")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp append_blogmark_toasts(socket, title, toasts) do
|
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")
|
||||||
@@ -836,8 +1012,31 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> UrlState.push()
|
|> UrlState.push()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A tab opened before the client's stored session arrives (e.g. a blogmark
|
||||||
|
# deep link that switched projects) must survive the restore, so re-open it
|
||||||
|
# on the restored workbench with its original transient/pinned intent.
|
||||||
|
defp preserve_active_tab(restored, %{active_tab: nil}), do: restored
|
||||||
|
|
||||||
|
defp preserve_active_tab(restored, %{active_tab: {type, id}} = previous) do
|
||||||
|
intent =
|
||||||
|
case Enum.find(previous.tabs, &(&1.type == type and &1.id == id)) do
|
||||||
|
%{is_transient: true} -> :preview
|
||||||
|
_ -> :pin
|
||||||
|
end
|
||||||
|
|
||||||
|
Workbench.open_tab(restored, type, id, intent)
|
||||||
|
end
|
||||||
|
|
||||||
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
|
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
|
||||||
|
|
||||||
|
# Register this shell with the deep-link router and drain any links queued
|
||||||
|
# before connect (cold start). The router is absent in headless/test runs.
|
||||||
|
defp attach_deep_link do
|
||||||
|
if Process.whereis(BDS.Desktop.DeepLink) do
|
||||||
|
BDS.Desktop.DeepLink.attach(self())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp sidebar_create_action(view), do: SidebarCreate.action(view)
|
defp sidebar_create_action(view), do: SidebarCreate.action(view)
|
||||||
|
|
||||||
defp set_page_language(socket, language) do
|
defp set_page_language(socket, language) do
|
||||||
@@ -859,6 +1058,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
socket
|
socket
|
||||||
else
|
else
|
||||||
UILocale.put(normalized)
|
UILocale.put(normalized)
|
||||||
|
# Persist server-side so every client renders the same language; the
|
||||||
|
# resulting settings_changed broadcast is a no-op for this socket.
|
||||||
|
_ = BDS.Settings.put_global_setting("ui.language", normalized)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:page_language, normalized)
|
|> assign(:page_language, normalized)
|
||||||
@@ -939,7 +1141,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp handle_socket_menu_action(socket, :save), do: TabActions.save_current_tab(socket)
|
defp handle_socket_menu_action(socket, :save), do: TabActions.save_current_tab(socket)
|
||||||
|
|
||||||
defp handle_socket_menu_action(socket, :publish_selected), do: TabActions.publish_current_tab(socket)
|
defp handle_socket_menu_action(socket, :publish_selected),
|
||||||
|
do: TabActions.publish_current_tab(socket)
|
||||||
|
|
||||||
defp handle_socket_menu_action(socket, :quit) do
|
defp handle_socket_menu_action(socket, :quit) do
|
||||||
Shutdown.request_quit()
|
Shutdown.request_quit()
|
||||||
@@ -956,6 +1159,34 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
socket
|
socket
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp handle_socket_menu_action(socket, :connect_server) do
|
||||||
|
prompt = dgettext("ui", "Server address (user@host or user@host:port), public-key auth:")
|
||||||
|
|
||||||
|
with {:ok, target} <- BDS.Desktop.Dialogs.prompt_text(prompt, "user@host"),
|
||||||
|
{:ok, url} <- BDS.Desktop.RemoteConnection.connect(target) do
|
||||||
|
Desktop.Window.show(BDS.Desktop.MainWindow.window_id(), url)
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
:cancel ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
dgettext("ui", "Connect to Server"),
|
||||||
|
BDS.Desktop.RemoteConnection.format_reason(reason),
|
||||||
|
nil,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_socket_menu_action(socket, :disconnect_server) do
|
||||||
|
:ok = BDS.Desktop.RemoteConnection.disconnect()
|
||||||
|
Desktop.Window.show(BDS.Desktop.MainWindow.window_id(), BDS.Desktop.url())
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_socket_menu_action(socket, :about) do
|
defp handle_socket_menu_action(socket, :about) do
|
||||||
append_output_entry(
|
append_output_entry(
|
||||||
socket,
|
socket,
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
import Phoenix.LiveView, only: [connected?: 1, send_update: 2]
|
import Phoenix.LiveView, only: [connected?: 1, send_update: 2]
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellData
|
||||||
alias BDS.Desktop.ShellLive.{ChatEditor, PostEditor}
|
alias BDS.Desktop.ShellLive.{ChatEditor, MediaEditor, PostEditor}
|
||||||
alias BDS.Desktop.ShellLive.{CliSync, SessionUtil}
|
alias BDS.Desktop.ShellLive.{CliSync, SessionUtil}
|
||||||
alias BDS.UI.Workbench
|
alias BDS.UI.Workbench
|
||||||
|
|
||||||
@refreshable_tab_meta_types [:import, :chat]
|
@refreshable_tab_meta_types [:import, :chat, :post, :media]
|
||||||
|
|
||||||
@spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) ::
|
@spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) ::
|
||||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||||
@@ -216,6 +216,26 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({:editor_translation_completed, :post, post_id, language}, socket, _callbacks) do
|
||||||
|
send_update(PostEditor,
|
||||||
|
id: "post-editor-#{post_id}",
|
||||||
|
action: :translation_completed,
|
||||||
|
language: language
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:editor_translation_completed, :media, media_id, language}, socket, _callbacks) do
|
||||||
|
send_update(MediaEditor,
|
||||||
|
id: "media-editor-#{media_id}",
|
||||||
|
action: :translation_completed,
|
||||||
|
language: language
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do
|
def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do
|
||||||
send_update(PostEditor,
|
send_update(PostEditor,
|
||||||
id: "post-editor-#{post_id}",
|
id: "post-editor-#{post_id}",
|
||||||
@@ -246,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:task_status, task_status)
|
|> assign(:task_status, task_status)
|
||||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
|> assign(:editor_meta, ShellData.editor_meta(task_status, socket.assigns.page_language))
|
||||||
|> assign(
|
|> assign(
|
||||||
:status,
|
:status,
|
||||||
ShellData.status_bar(
|
ShellData.status_bar(
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<h2><%= dgettext("ui", "API Key Required") %></h2>
|
<h2><%= dgettext("ui", "API Key Required") %></h2>
|
||||||
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
|
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
|
||||||
<div class="api-key-form">
|
<div class="api-key-form">
|
||||||
<button class="api-key-submit ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button>
|
<button class="api-key-submit primary ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
|
|
||||||
<form class="chat-input-wrapper flex items-end gap-2" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
|
<form class="chat-input-wrapper flex items-end gap-2" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
|
||||||
<textarea class="chat-input chat-surface-input ui-textarea" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
|
<textarea class="chat-input chat-surface-input ui-textarea" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
|
||||||
<button class="chat-send-button ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself}>↑</button>
|
<button class="chat-send-button primary ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself}>↑</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<%= if @chat_editor.action_error do %>
|
<%= if @chat_editor.action_error do %>
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ defmodule BDS.Desktop.ShellLive.ContentState do
|
|||||||
|> assign(:tab_meta, tab_meta)
|
|> assign(:tab_meta, tab_meta)
|
||||||
|> assign(:task_status, task_status)
|
|> assign(:task_status, task_status)
|
||||||
|> assign(:offline_mode, offline_mode)
|
|> assign(:offline_mode, offline_mode)
|
||||||
|> assign(:assistant_cards, ShellData.assistant_cards())
|
|
||||||
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|
||||||
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|
||||||
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|
||||||
|
|||||||
@@ -15,14 +15,7 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
|
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
|
||||||
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
|
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
|
||||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
main_language = metadata.main_language || language
|
translate_targets = translate_targets(metadata, language)
|
||||||
blog_languages = metadata.blog_languages || []
|
|
||||||
|
|
||||||
translate_targets =
|
|
||||||
[main_language | blog_languages]
|
|
||||||
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
|
||||||
|> Enum.uniq()
|
|
||||||
|
|
||||||
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
|
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
|
||||||
|
|
||||||
tasks =
|
tasks =
|
||||||
@@ -48,6 +41,36 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
send(parent, {:add_images_complete, length(paths)})
|
send(parent, {:add_images_complete, length(paths)})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The translation targets for AI-generated media texts: the project main
|
||||||
|
language plus all blog languages, minus the source `language`.
|
||||||
|
"""
|
||||||
|
def translate_targets(metadata, language) do
|
||||||
|
main_language = metadata.main_language || language
|
||||||
|
blog_languages = metadata.blog_languages || []
|
||||||
|
|
||||||
|
[main_language | blog_languages]
|
||||||
|
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Imports a single file and runs the same best-effort AI enrichment
|
||||||
|
(title/alt/caption plus translations) as the gallery pipeline, without
|
||||||
|
linking the media to a post. Used by the CLI `media` command (issue #25).
|
||||||
|
|
||||||
|
Returns `{:ok, media, display_title}` or `{:error, reason}`.
|
||||||
|
"""
|
||||||
|
def import_and_enrich(path, project_id, language, translate_targets) do
|
||||||
|
case Media.import_media(%{project_id: project_id, source_path: path}) do
|
||||||
|
{:ok, media} ->
|
||||||
|
{:ok, media, enrich_media(media, language, translate_targets)}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp drain_tasks(
|
defp drain_tasks(
|
||||||
[],
|
[],
|
||||||
tasks,
|
tasks,
|
||||||
@@ -167,18 +190,14 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
translate_targets,
|
translate_targets,
|
||||||
parent
|
parent
|
||||||
) do
|
) do
|
||||||
|
# Linking is mandatory and MUST happen regardless of AI availability
|
||||||
|
# (airplane mode / no local model), mirroring the drag-drop chain and the
|
||||||
|
# old app: import -> link -> (optional) AI enrichment. Gating the link
|
||||||
|
# behind AI is what left galleries empty.
|
||||||
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
|
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
|
||||||
true <- String.starts_with?(media.mime_type || "", "image/"),
|
true <- String.starts_with?(media.mime_type || "", "image/"),
|
||||||
{:ok, result} <- AI.analyze_image(media.id, language: language),
|
|
||||||
{:ok, _updated} <-
|
|
||||||
Media.update_media(media.id, %{
|
|
||||||
title: result.title,
|
|
||||||
alt: result.alt,
|
|
||||||
caption: result.caption
|
|
||||||
}),
|
|
||||||
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
||||||
translate_media_translations(media.id, translate_targets)
|
title = enrich_media(media, language, translate_targets)
|
||||||
title = result.title || media.original_name
|
|
||||||
send(parent, {:add_image_processed, title})
|
send(parent, {:add_image_processed, title})
|
||||||
else
|
else
|
||||||
false ->
|
false ->
|
||||||
@@ -190,6 +209,32 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Best-effort AI enrichment: analysis + metadata update + translations. Any
|
||||||
|
# failure (offline, no model) is logged and never unlinks the image. Returns
|
||||||
|
# the display title used for progress reporting.
|
||||||
|
defp enrich_media(media, language, translate_targets) do
|
||||||
|
case AI.analyze_image(media.id, language: language) do
|
||||||
|
{:ok, result} ->
|
||||||
|
case Media.update_media(media.id, %{
|
||||||
|
title: result.title,
|
||||||
|
alt: result.alt,
|
||||||
|
caption: result.caption
|
||||||
|
}) do
|
||||||
|
{:ok, _updated} ->
|
||||||
|
translate_media_translations(media.id, translate_targets)
|
||||||
|
result.title || media.original_name
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Media metadata update failed for #{media.id}: #{inspect(reason)}")
|
||||||
|
media.original_name
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("AI image analysis skipped for #{media.id}: #{inspect(reason)}")
|
||||||
|
media.original_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp translate_media_translations(_media_id, []), do: :ok
|
defp translate_media_translations(_media_id, []), do: :ok
|
||||||
|
|
||||||
defp translate_media_translations(media_id, [target | rest]) do
|
defp translate_media_translations(media_id, [target | rest]) do
|
||||||
|
|||||||
130
lib/bds/desktop/shell_live/git_run.ex
Normal file
130
lib/bds/desktop/shell_live/git_run.ex
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.GitRun do
|
||||||
|
@moduledoc """
|
||||||
|
Live modal for streaming network git commands (fetch/pull/push).
|
||||||
|
|
||||||
|
Starts a `BDS.Git.Stream`, appends its stdout/stderr chunks as they arrive
|
||||||
|
(stderr rendered in red), shows a spinner while running and a pass/fail banner
|
||||||
|
from the exit status. Closing the modal cancels a still-running command, which
|
||||||
|
kills the OS process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
|
alias BDS.Git
|
||||||
|
|
||||||
|
@operations %{"git_fetch" => :fetch, "git_pull" => :pull, "git_push" => :push}
|
||||||
|
|
||||||
|
@doc "True when the shell event is a streaming network git action."
|
||||||
|
def network_event?(event), do: Map.has_key?(@operations, event)
|
||||||
|
|
||||||
|
@doc "Open the modal and start streaming the operation."
|
||||||
|
def start(socket, event, project_id) do
|
||||||
|
operation = Map.fetch!(@operations, event)
|
||||||
|
|
||||||
|
run =
|
||||||
|
case start_stream(project_id, operation) do
|
||||||
|
{:ok, pid, ref} ->
|
||||||
|
%{operation: operation, pid: pid, ref: ref, lines: [], status: :running}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
%{
|
||||||
|
operation: operation,
|
||||||
|
pid: nil,
|
||||||
|
ref: nil,
|
||||||
|
lines: [{:stderr, error_text(reason)}],
|
||||||
|
status: {:done, 1}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assign(socket, :git_run, run)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Append a streamed chunk to the modal (newest first internally)."
|
||||||
|
def append(socket, ref, stream, chunk) do
|
||||||
|
case socket.assigns[:git_run] do
|
||||||
|
%{ref: ^ref, lines: lines} = run ->
|
||||||
|
assign(socket, :git_run, %{run | lines: [{stream, chunk} | lines]})
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Mark the run finished with its exit status."
|
||||||
|
def done(socket, ref, status) do
|
||||||
|
case socket.assigns[:git_run] do
|
||||||
|
%{ref: ^ref} = run ->
|
||||||
|
assign(socket, :git_run, %{run | status: {:done, status}, pid: nil})
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Close the modal, cancelling (killing) a still-running command."
|
||||||
|
def close(socket) do
|
||||||
|
case socket.assigns[:git_run] do
|
||||||
|
%{status: :running, pid: pid} when is_pid(pid) -> Git.Stream.cancel(pid)
|
||||||
|
_other -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
assign(socket, :git_run, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_stream(nil, _operation), do: {:error, :no_project}
|
||||||
|
defp start_stream("default", _operation), do: {:error, :no_project}
|
||||||
|
|
||||||
|
defp start_stream(project_id, operation) when is_binary(project_id),
|
||||||
|
do: Git.stream(project_id, operation, self())
|
||||||
|
|
||||||
|
defp error_text(reason) when reason in [:no_project, :not_found],
|
||||||
|
do: dgettext("ui", "No active project") <> "\n"
|
||||||
|
|
||||||
|
defp error_text(reason), do: inspect(reason) <> "\n"
|
||||||
|
|
||||||
|
def git_run_modal(assigns) do
|
||||||
|
~H"""
|
||||||
|
<%= if @git_run do %>
|
||||||
|
<div class="shell-overlay-backdrop git-run-backdrop" data-testid="git-run-backdrop" phx-window-keydown="git_run_keydown">
|
||||||
|
<button class="shell-overlay-dismiss" type="button" phx-click="git_run_close" aria-label={dgettext("ui", "Close")}></button>
|
||||||
|
<div class="git-run-modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="git-run-header">
|
||||||
|
<h2 class="git-run-title">{title(@git_run.operation)}</h2>
|
||||||
|
<span class={["git-run-status", status_class(@git_run.status)]} data-testid="git-run-status">
|
||||||
|
{status_label(@git_run.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre class="git-run-output" data-testid="git-run-output"><%= for {stream, text} <- Enum.reverse(@git_run.lines) do %><span class={line_class(stream)}>{text}</span><% end %></pre>
|
||||||
|
<div class="git-run-footer">
|
||||||
|
<button class="git-action-button" type="button" phx-click="git_run_close" data-testid="git-run-close">
|
||||||
|
{close_label(@git_run.status)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp title(:fetch), do: dgettext("ui", "git fetch")
|
||||||
|
defp title(:pull), do: dgettext("ui", "git pull")
|
||||||
|
defp title(:push), do: dgettext("ui", "git push")
|
||||||
|
|
||||||
|
defp line_class(:stderr), do: "git-run-line git-run-stderr"
|
||||||
|
defp line_class(:stdout), do: "git-run-line git-run-stdout"
|
||||||
|
|
||||||
|
defp status_class(:running), do: "running"
|
||||||
|
defp status_class({:done, 0}), do: "ok"
|
||||||
|
defp status_class({:done, _status}), do: "fail"
|
||||||
|
|
||||||
|
defp status_label(:running), do: dgettext("ui", "Running…")
|
||||||
|
defp status_label({:done, 0}), do: dgettext("ui", "Done")
|
||||||
|
|
||||||
|
defp status_label({:done, status}),
|
||||||
|
do: dgettext("ui", "Failed (exit %{status})", status: status)
|
||||||
|
|
||||||
|
defp close_label(:running), do: dgettext("ui", "Cancel")
|
||||||
|
defp close_label({:done, _status}), do: dgettext("ui", "Close")
|
||||||
|
end
|
||||||
@@ -840,7 +840,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
<div class={["import-file-path", if(blank?(@import_editor.uploads_folder_path), do: "placeholder")]}>
|
<div class={["import-file-path", if(blank?(@import_editor.uploads_folder_path), do: "placeholder")]}>
|
||||||
<%= @import_editor.uploads_folder_path || dgettext("ui", "No folder selected") %>
|
<%= @import_editor.uploads_folder_path || dgettext("ui", "No folder selected") %>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" phx-click="select_import_uploads_folder" phx-target={@myself}><%= dgettext("ui", "Open") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="select_import_uploads_folder" phx-target={@myself}><%= dgettext("ui", "Open") %></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="import-file-row">
|
<div class="import-file-row">
|
||||||
@@ -848,7 +848,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
<div class={["import-file-path", if(blank?(@import_editor.wxr_file_path), do: "placeholder")]}>
|
<div class={["import-file-path", if(blank?(@import_editor.wxr_file_path), do: "placeholder")]}>
|
||||||
<%= @import_editor.wxr_file_path || dgettext("ui", "Select a file to analyze") %>
|
<%= @import_editor.wxr_file_path || dgettext("ui", "Select a file to analyze") %>
|
||||||
</div>
|
</div>
|
||||||
<button class="import-analyze-btn" type="button" phx-click="select_import_wxr_file" phx-target={@myself}><%= dgettext("ui", "Select & Analyze") %></button>
|
<button class="import-analyze-btn primary ui-button ui-button-primary" type="button" phx-click="select_import_wxr_file" phx-target={@myself}><%= dgettext("ui", "Select & Analyze") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -944,7 +944,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
<%= if @counts.pages > 0 do %><span class="import-count-tag"><%= @counts.pages %> <%= dgettext("ui", "pages") %></span><% end %>
|
<%= if @counts.pages > 0 do %><span class="import-count-tag"><%= @counts.pages %> <%= dgettext("ui", "pages") %></span><% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="import-execute-btn" type="button" phx-click="execute_import_editor" phx-target={@myself} disabled={@counts.total == 0}>
|
<button class="import-execute-btn primary ui-button ui-button-primary" type="button" phx-click="execute_import_editor" phx-target={@myself} disabled={@counts.total == 0}>
|
||||||
<%= if @counts.total == 0 do %>
|
<%= if @counts.total == 0 do %>
|
||||||
<%= dgettext("ui", "Nothing to Import") %>
|
<%= dgettext("ui", "Nothing to Import") %>
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -1000,7 +1000,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
<%= if @sections.taxonomy do %>
|
<%= if @sections.taxonomy do %>
|
||||||
<div class="taxonomy-analyze-row">
|
<div class="taxonomy-analyze-row">
|
||||||
<div class="taxonomy-analyze-dropdown">
|
<div class="taxonomy-analyze-dropdown">
|
||||||
<button class="taxonomy-analyze-btn" type="button" phx-click="toggle_import_ai_model_selector" phx-target={@myself}><%= dgettext("ui", "Analyze with...") %></button>
|
<button class="taxonomy-analyze-btn secondary ui-button ui-button-secondary" type="button" phx-click="toggle_import_ai_model_selector" phx-target={@myself}><%= dgettext("ui", "Analyze with...") %></button>
|
||||||
<%= if @import_editor.model_selector_open? do %>
|
<%= if @import_editor.model_selector_open? do %>
|
||||||
<div class="taxonomy-model-dropdown">
|
<div class="taxonomy-model-dropdown">
|
||||||
<%= for model <- @import_editor.available_models do %>
|
<%= for model <- @import_editor.available_models do %>
|
||||||
@@ -1012,7 +1012,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="taxonomy-analyze-btn" type="button" phx-click="analyze_import_taxonomy_ai" phx-target={@myself} disabled={Enum.empty?(@import_editor.available_models) and not @import_editor.offline?}>
|
<button class="taxonomy-analyze-btn secondary ui-button ui-button-secondary" type="button" phx-click="analyze_import_taxonomy_ai" phx-target={@myself} disabled={Enum.empty?(@import_editor.available_models) and not @import_editor.offline?}>
|
||||||
<%= @import_editor.selected_model_label %>
|
<%= @import_editor.selected_model_label %>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -1362,7 +1362,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
list={"taxonomy-suggestions-#{@type}"}
|
list={"taxonomy-suggestions-#{@type}"}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<button class="taxonomy-edit-btn" type="submit" title={dgettext("ui", "Map to...")}>✓</button>
|
<button class="taxonomy-edit-btn secondary" type="submit" title={dgettext("ui", "Map to...")}>✓</button>
|
||||||
<button class="taxonomy-edit-btn ghost" type="button" phx-click="cancel_import_taxonomy_edit" phx-target={@myself} title={dgettext("ui", "Cancel")}>×</button>
|
<button class="taxonomy-edit-btn ghost" type="button" phx-click="cancel_import_taxonomy_edit" phx-target={@myself} title={dgettext("ui", "Cancel")}>×</button>
|
||||||
<%= if present?(item.mapped_to) do %>
|
<%= if present?(item.mapped_to) do %>
|
||||||
<button class="taxonomy-clear-btn" type="button" phx-click="clear_import_taxonomy_mapping" phx-target={@myself} phx-value-type={@type} phx-value-name={item.name} title={dgettext("ui", "Clear mapping")}>×</button>
|
<button class="taxonomy-clear-btn" type="button" phx-click="clear_import_taxonomy_mapping" phx-target={@myself} phx-value-type={@type} phx-value-name={item.name} title={dgettext("ui", "Clear mapping")}>×</button>
|
||||||
|
|||||||
@@ -457,7 +457,7 @@
|
|||||||
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
|
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
|
||||||
<%= for tab <- @panel_tabs do %>
|
<%= for tab <- @panel_tabs do %>
|
||||||
<button
|
<button
|
||||||
class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex h-9 items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]}
|
class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="select_panel_tab"
|
phx-click="select_panel_tab"
|
||||||
phx-value-tab={tab}
|
phx-value-tab={tab}
|
||||||
@@ -531,7 +531,7 @@
|
|||||||
><%= @assistant_prompt %></textarea>
|
><%= @assistant_prompt %></textarea>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="assistant-sidebar-start-button ui-button ui-button-primary"
|
class="assistant-sidebar-start-button primary ui-button ui-button-primary"
|
||||||
data-testid="assistant-start-button"
|
data-testid="assistant-start-button"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={String.trim(@assistant_prompt || "") == ""}
|
disabled={String.trim(@assistant_prompt || "") == ""}
|
||||||
@@ -540,16 +540,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<%= if Enum.empty?(@assistant_messages) do %>
|
<%= unless Enum.empty?(@assistant_messages) do %>
|
||||||
<div class="assistant-sidebar-welcome min-h-0 flex-1 overflow-auto">
|
|
||||||
<%= for card <- @assistant_cards do %>
|
|
||||||
<section class="assistant-card flex flex-col gap-1">
|
|
||||||
<strong><%= card.label %></strong>
|
|
||||||
<span><%= card.text %></span>
|
|
||||||
</section>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="assistant-sidebar-transcript min-h-0 flex-1 overflow-auto">
|
<div class="assistant-sidebar-transcript min-h-0 flex-1 overflow-auto">
|
||||||
<%= for message <- @assistant_messages do %>
|
<%= for message <- @assistant_messages do %>
|
||||||
<article
|
<article
|
||||||
@@ -687,4 +678,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<ShellOverlayComponents.shell_overlay shell_overlay={@shell_overlay} />
|
<ShellOverlayComponents.shell_overlay shell_overlay={@shell_overlay} />
|
||||||
|
<LiveToast.toast_group flash={@flash} connected={assigns[:socket] != nil} toasts_sync={assigns[:toasts_sync]} corner={:bottom_right} toast_class_fn={&toast_class_fn/1} />
|
||||||
|
<GitRun.git_run_modal git_run={@git_run} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
|
|
||||||
alias BDS.Desktop.{FilePicker}
|
alias BDS.Desktop.{FilePicker}
|
||||||
alias BDS.Desktop.ShellLive.Notify
|
alias BDS.Desktop.ShellLive.Notify
|
||||||
alias BDS.{AI, I18n, Media}
|
alias BDS.{AI, I18n, Media, Tasks}
|
||||||
alias BDS.Media.Media, as: MediaRecord
|
alias BDS.Media.Media, as: MediaRecord
|
||||||
alias BDS.Media.Translation
|
alias BDS.Media.Translation
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
@@ -57,6 +57,17 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update(%{action: :translation_completed} = assigns, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(Map.drop(assigns, [:action, :language]))
|
||||||
|
|> assign(:quick_actions_open?, false)
|
||||||
|
|> assign(:editing_translation, nil)
|
||||||
|
|> build_data()
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -552,25 +563,34 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
media = socket.assigns.media
|
media = socket.assigns.media
|
||||||
normalized_language = normalize_language(language)
|
normalized_language = normalize_language(language)
|
||||||
source_language = normalize_language(media.language)
|
source_language = normalize_language(media.language)
|
||||||
|
parent = self()
|
||||||
|
|
||||||
case AI.translate_media(media.id, normalized_language, source_language: source_language) do
|
{:ok, _task} =
|
||||||
{:ok, translation} ->
|
Tasks.submit_task(
|
||||||
case Media.upsert_media_translation(media.id, normalized_language, translation) do
|
"Translate Media to #{normalized_language}",
|
||||||
{:ok, _saved_translation} ->
|
fn report ->
|
||||||
socket
|
report.(0.1, "Translating media to #{normalized_language}")
|
||||||
|> assign(:quick_actions_open?, false)
|
|
||||||
|> assign(:editing_translation, nil)
|
|
||||||
|> build_data()
|
|
||||||
|
|
||||||
{:error, reason} ->
|
with {:ok, translation} <-
|
||||||
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|
AI.translate_media(media.id, normalized_language,
|
||||||
|> build_data()
|
source_language: source_language
|
||||||
end
|
),
|
||||||
|
{:ok, _saved_translation} <-
|
||||||
|
Media.upsert_media_translation(media.id, normalized_language, translation) do
|
||||||
|
report.(1.0, "Media translation complete")
|
||||||
|
send(parent, {:editor_translation_completed, :media, media.id, normalized_language})
|
||||||
|
%{media_id: media.id, language: normalized_language}
|
||||||
|
else
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
%{group_id: media.project_id, group_name: "AI"}
|
||||||
|
)
|
||||||
|
|
||||||
{:error, reason} ->
|
socket
|
||||||
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|
|> assign(:quick_actions_open?, false)
|
||||||
|> build_data()
|
|> assign(:editing_translation, nil)
|
||||||
end
|
|> build_data()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -625,7 +645,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
|
|
||||||
defp preview_url(%MediaRecord{} = media) do
|
defp preview_url(%MediaRecord{} = media) do
|
||||||
if image?(media),
|
if image?(media),
|
||||||
do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}",
|
do: "/media-file/#{media.id}?t=#{media.updated_at}",
|
||||||
else: nil
|
else: nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="replace_media_editor_file" phx-target={@myself}>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="replace_media_editor_file" phx-target={@myself}>
|
||||||
<%= dgettext("ui", "Replace File") %>
|
<%= dgettext("ui", "Replace File") %>
|
||||||
</button>
|
</button>
|
||||||
<button class="ui-button ui-button-primary" data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-target={@myself}>
|
<button class="primary ui-button ui-button-primary" data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-target={@myself}>
|
||||||
<%= dgettext("ui", "Save") %>
|
<%= dgettext("ui", "Save") %>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
<div class="editor-field linked-posts-section flex flex-col gap-2">
|
<div class="editor-field linked-posts-section flex flex-col gap-2">
|
||||||
<label>
|
<label>
|
||||||
<%= dgettext("ui", "Linked Posts") %>
|
<%= dgettext("ui", "Linked Posts") %>
|
||||||
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-target={@myself}>
|
<button class="add-link-btn secondary compact ui-button ui-button-secondary ui-button-compact" type="button" phx-click="toggle_media_post_picker" phx-target={@myself}>
|
||||||
<%= dgettext("ui", "Link to Post") %>
|
<%= dgettext("ui", "Link to Post") %>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="translation-modal-footer flex items-center justify-end gap-2">
|
<div class="translation-modal-footer flex items-center justify-end gap-2">
|
||||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="close_media_translation_editor" phx-target={@myself}><%= dgettext("ui", "Cancel") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="close_media_translation_editor" phx-target={@myself}><%= dgettext("ui", "Cancel") %></button>
|
||||||
<button class="ui-button ui-button-primary" type="button" phx-click="save_media_translation" phx-target={@myself}><%= dgettext("ui", "Save") %></button>
|
<button class="primary ui-button ui-button-primary" type="button" phx-click="save_media_translation" phx-target={@myself}><%= dgettext("ui", "Save") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
|||||||
import Phoenix.HTML, only: [raw: 1]
|
import Phoenix.HTML, only: [raw: 1]
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.{Embeddings, Generation, Git, HelpDocs, Posts, Repo}
|
alias BDS.{Embeddings, Git, HelpDocs, Posts, Repo}
|
||||||
alias BDS.Desktop.ShellLive.Notify
|
alias BDS.Desktop.ShellLive.Notify
|
||||||
alias BDS.MapUtils
|
alias BDS.MapUtils
|
||||||
alias BDS.Settings.Setting
|
alias BDS.Settings.Setting
|
||||||
@@ -77,7 +77,6 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
|||||||
def handle_event("apply_site_validation", _params, socket) do
|
def handle_event("apply_site_validation", _params, socket) do
|
||||||
meta = meta(socket.assigns)
|
meta = meta(socket.assigns)
|
||||||
payload = Map.get(meta, :payload, %{})
|
payload = Map.get(meta, :payload, %{})
|
||||||
project_id = Map.get(meta, :project_id, socket.assigns.project_id)
|
|
||||||
|
|
||||||
report = %{
|
report = %{
|
||||||
sitemap_path: Map.get(payload, :sitemap_path),
|
sitemap_path: Map.get(payload, :sitemap_path),
|
||||||
@@ -89,21 +88,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
|||||||
existing_html_url_count: Map.get(payload, :existing_html_url_count, 0)
|
existing_html_url_count: Map.get(payload, :existing_html_url_count, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
case Generation.apply_validation(project_id, report) do
|
notify_command("apply_site_validation", %{report: report})
|
||||||
{:ok, result} ->
|
{:noreply, socket}
|
||||||
notify_output(
|
|
||||||
dgettext("ui", "Site Validation"),
|
|
||||||
dgettext("ui", "Validation changes applied"),
|
|
||||||
inspect(result)
|
|
||||||
)
|
|
||||||
|
|
||||||
notify_command("validate_site")
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
error ->
|
|
||||||
notify_output(dgettext("ui", "Site Validation"), inspect(error), nil, "error")
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("fix_translation_validation", _params, socket) do
|
def handle_event("fix_translation_validation", _params, socket) do
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ defmodule BDS.Desktop.ShellLive.Notify do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sends an output entry to a specific LiveView process. Needed when the
|
||||||
|
caller runs in a different process than the shell — e.g. script output
|
||||||
|
callbacks invoked inside the sandboxed script process.
|
||||||
|
"""
|
||||||
|
@spec output_to(pid(), String.t(), String.t(), String.t() | nil, String.t()) :: :ok
|
||||||
|
def output_to(pid, title, message, detail, level) when is_pid(pid) do
|
||||||
|
send(pid, {:editor_output, title, message, detail, level})
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec alert(String.t(), String.t()) :: :ok
|
||||||
|
def alert(title, message) do
|
||||||
|
send(self(), {:shell_alert, title, message})
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
@spec tab_meta(atom(), term(), String.t(), String.t()) :: :ok
|
@spec tab_meta(atom(), term(), String.t(), String.t()) :: :ok
|
||||||
def tab_meta(type, id, title, subtitle) do
|
def tab_meta(type, id, title, subtitle) do
|
||||||
send(self(), {:editor_tab_meta, type, id, %{title: title, subtitle: subtitle || ""}})
|
send(self(), {:editor_tab_meta, type, id, %{title: title, subtitle: subtitle || ""}})
|
||||||
|
|||||||
@@ -39,8 +39,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-suggestions-modal-footer">
|
<div class="ai-suggestions-modal-footer">
|
||||||
<button class="button-cancel" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
|
<button class="button-cancel secondary ui-button ui-button-secondary" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
|
||||||
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Apply Selected") %></button>
|
<button class="button-apply primary ui-button ui-button-primary" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Apply Selected") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<span class="insert-modal-label"><%= dgettext("ui", "Display Text") %></span>
|
<span class="insert-modal-label"><%= dgettext("ui", "Display Text") %></span>
|
||||||
<input class="insert-modal-input" type="text" name="overlay[text]" value={@shell_overlay.external_text} />
|
<input class="insert-modal-input" type="text" name="overlay[text]" value={@shell_overlay.external_text} />
|
||||||
</label>
|
</label>
|
||||||
<button class="insert-modal-submit" type="button" phx-click="overlay_insert_external"><%= dgettext("ui", "Insert") %></button>
|
<button class="insert-modal-submit primary ui-button ui-button-primary" type="button" phx-click="overlay_insert_external"><%= dgettext("ui", "Insert") %></button>
|
||||||
</form>
|
</form>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,8 +162,8 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="confirm-delete-modal-footer">
|
<div class="confirm-delete-modal-footer">
|
||||||
<button class="button-cancel" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
|
<button class="button-cancel secondary ui-button ui-button-secondary" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
|
||||||
<button class="button-delete" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Delete") %></button>
|
<button class="button-delete danger ui-button ui-button-danger" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Delete") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,8 +180,25 @@
|
|||||||
<div class="confirm-delete-message"><%= @shell_overlay.message %></div>
|
<div class="confirm-delete-message"><%= @shell_overlay.message %></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="confirm-delete-modal-footer">
|
<div class="confirm-delete-modal-footer">
|
||||||
<button class="button-cancel" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
|
<button class="button-cancel secondary ui-button ui-button-secondary" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
|
||||||
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Confirm") %></button>
|
<button class="button-apply primary ui-button ui-button-primary" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Confirm") %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% :alert -> %>
|
||||||
|
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||||
|
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={dgettext("ui", "Close")}></button>
|
||||||
|
<div class={["confirm-delete-modal", "alert-modal", "alert-modal-#{@shell_overlay.tone}"]} role="alertdialog" aria-modal="true">
|
||||||
|
<div class="confirm-delete-modal-header">
|
||||||
|
<h2><%= @shell_overlay.title %></h2>
|
||||||
|
<button class="confirm-delete-modal-close" type="button" phx-click="close_overlay">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-delete-modal-body">
|
||||||
|
<div class="confirm-delete-message"><%= @shell_overlay.message %></div>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-delete-modal-footer">
|
||||||
|
<button class="button-apply primary ui-button ui-button-primary" type="button" phx-click="close_overlay"><%= dgettext("ui", "OK") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -215,7 +216,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
|||||||
case {socket.assigns[:shell_overlay], current_tab} do
|
case {socket.assigns[:shell_overlay], current_tab} do
|
||||||
{%{kind: :language_picker}, %{type: :post, id: post_id}} ->
|
{%{kind: :language_picker}, %{type: :post, id: post_id}} ->
|
||||||
Notify.parent({:post_editor_translate, post_id, code})
|
Notify.parent({:post_editor_translate, post_id, code})
|
||||||
socket
|
assign(socket, :shell_overlay, nil)
|
||||||
|
|
||||||
{%{kind: :language_picker}, %{type: :media, id: media_id}} ->
|
{%{kind: :language_picker}, %{type: :media, id: media_id}} ->
|
||||||
send_update(MediaEditor,
|
send_update(MediaEditor,
|
||||||
@@ -224,7 +225,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
|||||||
language: code
|
language: code
|
||||||
)
|
)
|
||||||
|
|
||||||
socket
|
assign(socket, :shell_overlay, nil)
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
socket
|
socket
|
||||||
|
|||||||
@@ -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={[
|
||||||
|
|||||||
@@ -3,16 +3,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
|
|
||||||
use Phoenix.LiveComponent
|
use Phoenix.LiveComponent
|
||||||
|
|
||||||
alias BDS.{AI, Metadata, Posts, Preview}
|
alias BDS.{AI, Embeddings, Metadata, Posts, Preview, Tasks}
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellData
|
||||||
alias BDS.Desktop.ShellLive.{EditorImageDrop, Notify}
|
alias BDS.Desktop.ShellLive.{EditorImageDrop, Notify}
|
||||||
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata}
|
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
alias BDS.Tags
|
alias BDS.Tags
|
||||||
|
alias BDS.UI.PostEditor.{Draft, ListValues, Persistence}
|
||||||
|
|
||||||
import DraftManagement,
|
import Draft,
|
||||||
only: [
|
only: [
|
||||||
current_draft: 4,
|
|
||||||
editing_canonical_language?: 3,
|
editing_canonical_language?: 3,
|
||||||
normalize_language: 2,
|
normalize_language: 2,
|
||||||
normalize_mode: 1,
|
normalize_mode: 1,
|
||||||
@@ -49,7 +48,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
|
|
||||||
use Gettext, backend: BDS.Gettext
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
import PostMetadata,
|
import BDS.UI.PostEditor.Metadata,
|
||||||
only: [
|
only: [
|
||||||
blank?: 1,
|
blank?: 1,
|
||||||
blank_to_nil: 1,
|
blank_to_nil: 1,
|
||||||
@@ -82,6 +81,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update(%{action: :auto_save} = assigns, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(Map.drop(assigns, [:action]))
|
||||||
|
|> do_save(true)
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def update(%{action: :publish} = assigns, socket) do
|
def update(%{action: :publish} = assigns, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -109,7 +117,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
id: socket.assigns.post_id,
|
id: socket.assigns.post_id,
|
||||||
content: content
|
content: content
|
||||||
})
|
})
|
||||||
|> assign(:shell_overlay, nil)
|
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
@@ -123,6 +130,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update(%{action: :translation_completed, language: language} = assigns, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(Map.drop(assigns, [:action, :language]))
|
||||||
|
|> apply_translation_completed(language)
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def update(%{action: :apply_ai_suggestions, fields: fields} = assigns, socket) do
|
def update(%{action: :apply_ai_suggestions, fields: fields} = assigns, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -279,6 +295,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
{:noreply, do_detect_language(socket)}
|
{:noreply, do_detect_language(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("focus_post_editor_tags", _params, socket) do
|
||||||
|
{:noreply, socket |> load_tag_semantic_suggestions() |> build_data()}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("add_post_editor_tag", %{"tag" => tag}, socket) do
|
def handle_event("add_post_editor_tag", %{"tag" => tag}, socket) do
|
||||||
{:noreply, do_add_list_value(socket, :tags, tag)}
|
{:noreply, do_add_list_value(socket, :tags, tag)}
|
||||||
end
|
end
|
||||||
@@ -295,18 +315,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
{:noreply, do_remove_list_value(socket, :categories, category)}
|
{:noreply, do_remove_list_value(socket, :categories, category)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("insert_content", %{"content" => content}, socket) do
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{
|
|
||||||
id: socket.assigns.post_id,
|
|
||||||
content: content
|
|
||||||
})
|
|
||||||
|> assign(:shell_overlay, nil)
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("close_quick_actions", _params, socket) do
|
def handle_event("close_quick_actions", _params, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -316,6 +324,19 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Semantic tag suggestions read the embeddings index (no live model call), so
|
||||||
|
# they work offline; suggest_tags/2 returns [] when similarity is disabled.
|
||||||
|
defp load_tag_semantic_suggestions(socket) do
|
||||||
|
case socket.assigns.post do
|
||||||
|
%Post{} = post ->
|
||||||
|
{:ok, suggestions} = Embeddings.suggest_tags(post.id, [])
|
||||||
|
assign(socket, :tag_semantic_suggestions, suggestions)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp component_current_draft(socket, post, metadata, active_language) do
|
defp component_current_draft(socket, post, metadata, active_language) do
|
||||||
persisted = persisted_form(post, metadata, active_language)
|
persisted = persisted_form(post, metadata, active_language)
|
||||||
Map.get(socket.assigns.drafts, active_language, persisted)
|
Map.get(socket.assigns.drafts, active_language, persisted)
|
||||||
@@ -335,6 +356,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
active_language: canonical,
|
active_language: canonical,
|
||||||
drafts: %{},
|
drafts: %{},
|
||||||
tag_query: "",
|
tag_query: "",
|
||||||
|
tag_semantic_suggestions: [],
|
||||||
category_query: "",
|
category_query: "",
|
||||||
quick_actions_open?: false,
|
quick_actions_open?: false,
|
||||||
mode: :markdown,
|
mode: :markdown,
|
||||||
@@ -426,6 +448,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
Tags.list_tags(post.project_id),
|
Tags.list_tags(post.project_id),
|
||||||
socket.assigns.tag_query
|
socket.assigns.tag_query
|
||||||
),
|
),
|
||||||
|
tag_semantic_suggestions:
|
||||||
|
Enum.reject(
|
||||||
|
socket.assigns.tag_semantic_suggestions || [],
|
||||||
|
&(&1 in tag_values(form))
|
||||||
|
),
|
||||||
category_suggestions:
|
category_suggestions:
|
||||||
category_suggestions(
|
category_suggestions(
|
||||||
form,
|
form,
|
||||||
@@ -451,7 +478,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_save(socket) do
|
defp do_save(socket, keep_draft? \\ false) do
|
||||||
post = socket.assigns.post
|
post = socket.assigns.post
|
||||||
|
|
||||||
case post do
|
case post do
|
||||||
@@ -463,15 +490,26 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
active_language = socket.assigns.active_language
|
active_language = socket.assigns.active_language
|
||||||
draft = component_current_draft(socket, post, metadata, active_language)
|
draft = component_current_draft(socket, post, metadata, active_language)
|
||||||
|
|
||||||
case persist(post, draft, active_language, metadata, :save) do
|
action = if keep_draft?, do: :auto_save, else: :save
|
||||||
|
|
||||||
|
case persist(post, draft, active_language, metadata, action) do
|
||||||
{:ok, record} ->
|
{:ok, record} ->
|
||||||
refreshed_post = Posts.get_post!(post.id)
|
refreshed_post = Posts.get_post!(post.id)
|
||||||
_refreshed_form = persisted_form(refreshed_post, metadata, active_language)
|
_refreshed_form = persisted_form(refreshed_post, metadata, active_language)
|
||||||
|
|
||||||
|
# Autosave keeps the working draft so the live editor stays the source
|
||||||
|
# of truth; only an explicit save drops it and reloads canonical body.
|
||||||
|
drafts =
|
||||||
|
if keep_draft? do
|
||||||
|
socket.assigns.drafts
|
||||||
|
else
|
||||||
|
Map.delete(socket.assigns.drafts, active_language)
|
||||||
|
end
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:post, refreshed_post)
|
|> assign(:post, refreshed_post)
|
||||||
|> assign(:drafts, Map.delete(socket.assigns.drafts, active_language))
|
|> assign(:drafts, drafts)
|
||||||
|> assign(:save_state, save_state_for_action(:save))
|
|> assign(:save_state, save_state_for_action(:save))
|
||||||
|> assign(:dirty?, false)
|
|> assign(:dirty?, false)
|
||||||
|> build_data()
|
|> build_data()
|
||||||
@@ -768,34 +806,56 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
post_id = socket.assigns.post_id
|
post_id = socket.assigns.post_id
|
||||||
normalized_language = normalize_language(language, "")
|
normalized_language = normalize_language(language, "")
|
||||||
source_language = socket.assigns.canonical_language
|
source_language = socket.assigns.canonical_language
|
||||||
|
project_id = socket.assigns.post.project_id
|
||||||
|
parent = self()
|
||||||
|
|
||||||
case AI.translate_post(post_id, normalized_language, source_language: source_language) do
|
{:ok, _task} =
|
||||||
{:ok, translation} ->
|
Tasks.submit_task(
|
||||||
with {:ok, _saved_translation} <-
|
"Translate Post to #{normalized_language}",
|
||||||
Posts.upsert_post_translation(post_id, normalized_language, %{
|
fn report ->
|
||||||
title: translation.title,
|
report.(0.1, "Translating post to #{normalized_language}")
|
||||||
excerpt: translation.excerpt,
|
|
||||||
content: translation.content
|
|
||||||
}) do
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> assign(:active_language, normalized_language)
|
|
||||||
|> assign(:drafts, Map.delete(socket.assigns.drafts, normalized_language))
|
|
||||||
|> assign(:quick_actions_open?, false)
|
|
||||||
|> build_data()
|
|
||||||
|
|
||||||
Notify.dirty(:post, post_id, false)
|
with {:ok, translation} <-
|
||||||
socket
|
AI.translate_post(post_id, normalized_language,
|
||||||
else
|
source_language: source_language
|
||||||
{:error, reason} ->
|
),
|
||||||
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|
{:ok, _saved_translation} <-
|
||||||
|> build_data()
|
Posts.upsert_post_translation(post_id, normalized_language, %{
|
||||||
end
|
title: translation.title,
|
||||||
|
excerpt: translation.excerpt,
|
||||||
|
content: translation.content
|
||||||
|
}) do
|
||||||
|
report.(1.0, "Post translation complete")
|
||||||
|
send(parent, {:editor_translation_completed, :post, post_id, normalized_language})
|
||||||
|
%{post_id: post_id, language: normalized_language}
|
||||||
|
else
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
%{group_id: project_id, group_name: "AI"}
|
||||||
|
)
|
||||||
|
|
||||||
{:error, reason} ->
|
socket
|
||||||
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|
|> assign(:quick_actions_open?, false)
|
||||||
|
|> build_data()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_translation_completed(socket, language) do
|
||||||
|
case Posts.get_post(socket.assigns.post_id) do
|
||||||
|
nil ->
|
||||||
|
build_data(socket)
|
||||||
|
|
||||||
|
%Post{} = post ->
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:post, post)
|
||||||
|
|> assign(:active_language, language)
|
||||||
|
|> assign(:drafts, Map.delete(socket.assigns.drafts, language))
|
||||||
|> build_data()
|
|> build_data()
|
||||||
end
|
|
||||||
|
Notify.dirty(:post, post.id, false)
|
||||||
|
socket
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -819,7 +879,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
if map_size(attrs) == 0 do
|
if map_size(attrs) == 0 do
|
||||||
assign(socket, :shell_overlay, nil)
|
socket
|
||||||
else
|
else
|
||||||
case Posts.update_post(post_id, attrs) do
|
case Posts.update_post(post_id, attrs) do
|
||||||
{:ok, updated_post} ->
|
{:ok, updated_post} ->
|
||||||
@@ -837,7 +897,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
)
|
)
|
||||||
|> assign(:save_state, :dirty)
|
|> assign(:save_state, :dirty)
|
||||||
|> assign(:dirty?, true)
|
|> assign(:dirty?, true)
|
||||||
|> assign(:shell_overlay, nil)
|
|
||||||
|> build_data()
|
|> build_data()
|
||||||
|
|
||||||
Notify.dirty(:post, post_id, true)
|
Notify.dirty(:post, post_id, true)
|
||||||
@@ -941,7 +1000,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
active_language = socket.assigns.active_language
|
active_language = socket.assigns.active_language
|
||||||
post = socket.assigns.post
|
post = socket.assigns.post
|
||||||
metadata = socket.assigns.project_metadata
|
metadata = socket.assigns.project_metadata
|
||||||
draft = current_draft(socket.assigns, post, metadata, active_language)
|
draft = component_current_draft(socket, post, metadata, active_language)
|
||||||
updated = Map.put(draft, field, value)
|
updated = Map.put(draft, field, value)
|
||||||
assign(socket, :drafts, Map.put(socket.assigns.drafts, active_language, updated))
|
assign(socket, :drafts, Map.put(socket.assigns.drafts, active_language, updated))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
|
|
||||||
@moduledoc false
|
|
||||||
|
|
||||||
import Phoenix.Component, only: [assign: 3]
|
|
||||||
|
|
||||||
alias BDS.Posts
|
|
||||||
alias BDS.Posts.{Post, Translation}
|
|
||||||
alias BDS.Desktop.ShellLive.PostEditor.PostMetadata
|
|
||||||
alias BDS.UI.Workbench
|
|
||||||
|
|
||||||
@spec normalize_mode(term()) :: term()
|
|
||||||
def normalize_mode(mode) when mode in [:markdown, :preview], do: mode
|
|
||||||
@spec normalize_mode(term()) :: term()
|
|
||||||
def normalize_mode("visual"), do: :markdown
|
|
||||||
def normalize_mode("preview"), do: :preview
|
|
||||||
def normalize_mode(_mode), do: :markdown
|
|
||||||
|
|
||||||
@spec normalize_language(term(), term()) :: term()
|
|
||||||
def normalize_language(value, fallback) do
|
|
||||||
case value |> to_string() |> String.trim() do
|
|
||||||
"" -> fallback
|
|
||||||
normalized -> String.downcase(normalized)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec normalize_params(term(), term(), term()) :: term()
|
|
||||||
def normalize_params(params, current_language, next_language) do
|
|
||||||
%{
|
|
||||||
"title" => Map.get(params, "title", ""),
|
|
||||||
"excerpt" => Map.get(params, "excerpt", ""),
|
|
||||||
"content" => Map.get(params, "content", ""),
|
|
||||||
"tags" => Map.get(params, "tags", ""),
|
|
||||||
"categories" => Map.get(params, "categories", ""),
|
|
||||||
"author" => Map.get(params, "author", ""),
|
|
||||||
"language" =>
|
|
||||||
if(current_language == next_language,
|
|
||||||
do: normalize_language(Map.get(params, "language"), current_language),
|
|
||||||
else: next_language
|
|
||||||
),
|
|
||||||
"do_not_translate" => truthy?(Map.get(params, "do_not_translate")),
|
|
||||||
"template_slug" => Map.get(params, "template_slug", "")
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec current_draft(term(), term(), term(), term()) :: term()
|
|
||||||
def current_draft(assigns, %Post{} = post, metadata, active_language) do
|
|
||||||
persisted = persisted_form(post, metadata, active_language)
|
|
||||||
|
|
||||||
assigns.post_editor_drafts
|
|
||||||
|> Map.get(post.id, %{})
|
|
||||||
|> Map.get(active_language, persisted)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec persisted_form(term(), term(), term()) :: term()
|
|
||||||
def persisted_form(%Post{} = post, metadata, active_language) do
|
|
||||||
persisted_form(post, metadata, active_language, PostMetadata.translations(post.id))
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec persisted_form(term(), term(), term(), term()) :: term()
|
|
||||||
def persisted_form(post, metadata, active_language, translations) do
|
|
||||||
canonical_language = PostMetadata.canonical_language(post, metadata)
|
|
||||||
translation = Map.get(translations, active_language)
|
|
||||||
|
|
||||||
if active_language == canonical_language do
|
|
||||||
%{
|
|
||||||
"title" => post.title || "",
|
|
||||||
"excerpt" => post.excerpt || "",
|
|
||||||
"content" => Posts.editor_body(post),
|
|
||||||
"tags" => Enum.join(post.tags || [], ", "),
|
|
||||||
"categories" => Enum.join(post.categories || [], ", "),
|
|
||||||
"author" => post.author || metadata.default_author || "",
|
|
||||||
"language" => canonical_language,
|
|
||||||
"do_not_translate" => post.do_not_translate || false,
|
|
||||||
"template_slug" => post.template_slug || ""
|
|
||||||
}
|
|
||||||
else
|
|
||||||
%{
|
|
||||||
"title" => (translation && translation.title) || "",
|
|
||||||
"excerpt" => (translation && translation.excerpt) || "",
|
|
||||||
"content" => if(translation, do: Posts.editor_body(translation), else: ""),
|
|
||||||
"tags" => Enum.join(post.tags || [], ", "),
|
|
||||||
"categories" => Enum.join(post.categories || [], ", "),
|
|
||||||
"author" => post.author || metadata.default_author || "",
|
|
||||||
"language" => active_language,
|
|
||||||
"do_not_translate" => post.do_not_translate || false,
|
|
||||||
"template_slug" => post.template_slug || ""
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec maybe_update_draft(term(), term(), term(), term(), term(), term(), term()) :: term()
|
|
||||||
def maybe_update_draft(socket, post_id, post, current_language, next_language, draft, true) do
|
|
||||||
workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> assign(:workbench, workbench)
|
|
||||||
|> assign(
|
|
||||||
:post_editor_drafts,
|
|
||||||
put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)
|
|
||||||
)
|
|
||||||
|> assign(
|
|
||||||
:post_editor_active_languages,
|
|
||||||
Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)
|
|
||||||
)
|
|
||||||
|> assign(
|
|
||||||
:post_editor_save_states,
|
|
||||||
Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)
|
|
||||||
)
|
|
||||||
|> assign(
|
|
||||||
:tab_meta,
|
|
||||||
Map.put(socket.assigns.tab_meta, {:post, post_id}, %{
|
|
||||||
title: draft["title"],
|
|
||||||
subtitle: Atom.to_string(post.status || :draft)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|> maybe_drop_old_language_draft(post_id, current_language, next_language)
|
|
||||||
end
|
|
||||||
|
|
||||||
def maybe_update_draft(socket, post_id, _post, _current_language, next_language, _draft, false) do
|
|
||||||
assign(
|
|
||||||
socket,
|
|
||||||
:post_editor_active_languages,
|
|
||||||
Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec put_draft_field(term(), term(), term(), term(), term(), term()) :: term()
|
|
||||||
def put_draft_field(socket, post_id, post, active_language, field, value) do
|
|
||||||
metadata = PostMetadata.project_metadata(post.project_id)
|
|
||||||
draft = Map.put(current_draft(socket.assigns, post, metadata, active_language), field, value)
|
|
||||||
workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> assign(:workbench, workbench)
|
|
||||||
|> assign(
|
|
||||||
:post_editor_drafts,
|
|
||||||
put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, draft)
|
|
||||||
)
|
|
||||||
|> assign(
|
|
||||||
:post_editor_save_states,
|
|
||||||
Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec put_query_state(term(), term(), term(), term()) :: term()
|
|
||||||
def put_query_state(socket, post_id, kind, value) do
|
|
||||||
key = query_key(kind)
|
|
||||||
|
|
||||||
assign(
|
|
||||||
socket,
|
|
||||||
key,
|
|
||||||
Map.put(Map.get(socket.assigns, key, %{}), post_id, to_string(value || ""))
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec query_value(term(), term(), term()) :: term()
|
|
||||||
def query_value(assigns, kind, post_id) do
|
|
||||||
assigns
|
|
||||||
|> Map.get(query_key(kind), %{})
|
|
||||||
|> Map.get(post_id, "")
|
|
||||||
end
|
|
||||||
|
|
||||||
defp query_key(:tags), do: :post_editor_tag_queries
|
|
||||||
defp query_key(:categories), do: :post_editor_category_queries
|
|
||||||
|
|
||||||
defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language)
|
|
||||||
when current_language == next_language,
|
|
||||||
do: socket
|
|
||||||
|
|
||||||
defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do
|
|
||||||
assign(
|
|
||||||
socket,
|
|
||||||
:post_editor_drafts,
|
|
||||||
delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec toggled_sections(term(), term(), term()) :: term()
|
|
||||||
def toggled_sections(expanded_by_post, post_id, section) do
|
|
||||||
expanded_by_post
|
|
||||||
|> Map.get(post_id, %{metadata: false, excerpt: false})
|
|
||||||
|> Map.put_new(:metadata, false)
|
|
||||||
|> Map.put_new(:excerpt, false)
|
|
||||||
|> Map.update!(section, &(not &1))
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec put_nested_map(term(), term(), term(), term()) :: term()
|
|
||||||
def put_nested_map(map, key, nested_key, value) do
|
|
||||||
Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value))
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec delete_nested_map(term(), term(), term()) :: term()
|
|
||||||
def delete_nested_map(map, key, nested_key) do
|
|
||||||
case Map.get(map, key) do
|
|
||||||
nil ->
|
|
||||||
map
|
|
||||||
|
|
||||||
nested ->
|
|
||||||
case Map.delete(nested, nested_key) do
|
|
||||||
emptied when map_size(emptied) == 0 -> Map.delete(map, key)
|
|
||||||
remaining -> Map.put(map, key, remaining)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec reload_with_assigned_workbench(term(), term()) :: term()
|
|
||||||
def reload_with_assigned_workbench(socket, reload),
|
|
||||||
do: reload.(socket, socket.assigns.workbench)
|
|
||||||
|
|
||||||
@spec save_state_for_action(term()) :: term()
|
|
||||||
def save_state_for_action(:publish), do: :published
|
|
||||||
def save_state_for_action(_action), do: :saved
|
|
||||||
|
|
||||||
@spec record_title(term(), term()) :: term()
|
|
||||||
def record_title(%Translation{title: title}, post),
|
|
||||||
do: blank_to_nil(title) || post.title || post.slug || post.id
|
|
||||||
|
|
||||||
def record_title(%Post{title: title, slug: slug, id: id}, _post),
|
|
||||||
do: blank_to_nil(title) || blank_to_nil(slug) || id
|
|
||||||
|
|
||||||
@spec record_status(term()) :: term()
|
|
||||||
def record_status(%Translation{status: status}), do: status || :draft
|
|
||||||
def record_status(%Post{status: status}), do: status || :draft
|
|
||||||
|
|
||||||
@spec editing_canonical_language?(term(), term(), term()) :: term()
|
|
||||||
def editing_canonical_language?(translations, active_language, canonical_language) do
|
|
||||||
active_language == canonical_language or not Map.has_key?(translations, active_language)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp truthy?(value), do: BDS.Values.truthy?(value)
|
|
||||||
|
|
||||||
defp blank_to_nil(value) do
|
|
||||||
value
|
|
||||||
|> to_string()
|
|
||||||
|> String.trim()
|
|
||||||
|> case do
|
|
||||||
"" -> nil
|
|
||||||
trimmed -> trimmed
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -173,9 +173,22 @@
|
|||||||
value={@post_editor.tag_query}
|
value={@post_editor.tag_query}
|
||||||
placeholder={dgettext("ui", "Add tag")}
|
placeholder={dgettext("ui", "Add tag")}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
phx-focus="focus_post_editor_tags"
|
||||||
|
phx-target={@myself}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= if String.trim(@post_editor.tag_query || "") == "" and Enum.any?(@post_editor.tag_semantic_suggestions) do %>
|
||||||
|
<div class="tag-suggestions tag-suggestions-semantic mt-2 flex flex-col">
|
||||||
|
<div class="tag-suggestion-section-label"><%= dgettext("ui", "Suggested tags") %></div>
|
||||||
|
<%= for name <- @post_editor.tag_semantic_suggestions do %>
|
||||||
|
<button class="tag-suggestion ai-suggested" type="button" phx-click="add_post_editor_tag" phx-value-tag={name} phx-target={@myself}>
|
||||||
|
<span class="tag-suggestion-name"><%= name %></span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= if String.trim(@post_editor.tag_query || "") != "" and (Enum.any?(@post_editor.tag_suggestions) or @post_editor.tag_query_addable?) do %>
|
<%= if String.trim(@post_editor.tag_query || "") != "" and (Enum.any?(@post_editor.tag_suggestions) or @post_editor.tag_query_addable?) do %>
|
||||||
<div class="tag-suggestions mt-2 flex flex-col">
|
<div class="tag-suggestions mt-2 flex flex-col">
|
||||||
<%= for tag <- @post_editor.tag_suggestions do %>
|
<%= for tag <- @post_editor.tag_suggestions do %>
|
||||||
@@ -381,7 +394,7 @@
|
|||||||
<div class="editor-toolbar-right ui-toolbar-group flex items-center gap-2">
|
<div class="editor-toolbar-right ui-toolbar-group flex items-center gap-2">
|
||||||
<%= if @post_editor.mode == :markdown do %>
|
<%= if @post_editor.mode == :markdown do %>
|
||||||
<button
|
<button
|
||||||
class="insert-post-link-button"
|
class="insert-post-link-button secondary ui-button ui-button-secondary whitespace-nowrap"
|
||||||
data-testid="editor-toolbar-overlay-button"
|
data-testid="editor-toolbar-overlay-button"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="open_overlay"
|
phx-click="open_overlay"
|
||||||
@@ -390,7 +403,7 @@
|
|||||||
<%= dgettext("ui", "Insert Link") %>
|
<%= dgettext("ui", "Insert Link") %>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="insert-media-button"
|
class="insert-media-button secondary ui-button ui-button-secondary whitespace-nowrap"
|
||||||
data-testid="editor-toolbar-overlay-button"
|
data-testid="editor-toolbar-overlay-button"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="open_overlay"
|
phx-click="open_overlay"
|
||||||
@@ -399,7 +412,7 @@
|
|||||||
<%= dgettext("ui", "Insert Media") %>
|
<%= dgettext("ui", "Insert Media") %>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="add-gallery-images-button"
|
class="add-gallery-images-button secondary ui-button ui-button-secondary whitespace-nowrap"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="add_gallery_images"
|
phx-click="add_gallery_images"
|
||||||
phx-value-post-id={@post_editor.id}
|
phx-value-post-id={@post_editor.id}
|
||||||
@@ -410,7 +423,7 @@
|
|||||||
|
|
||||||
<%= if @post_editor.gallery_count > 0 do %>
|
<%= if @post_editor.gallery_count > 0 do %>
|
||||||
<button
|
<button
|
||||||
class="gallery-button"
|
class="gallery-button secondary ui-button ui-button-secondary whitespace-nowrap"
|
||||||
data-testid="editor-toolbar-overlay-button"
|
data-testid="editor-toolbar-overlay-button"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="open_overlay"
|
phx-click="open_overlay"
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -57,6 +57,27 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
|||||||
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
|
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
|
||||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||||
@impl true
|
@impl true
|
||||||
|
def handle_event("copy_blogmark_bookmarklet", _params, socket) do
|
||||||
|
project_id = socket.assigns.projects.active_project_id
|
||||||
|
bookmarklet = BDS.Scripting.Capabilities.AppShell.blogmark_bookmarklet(project_id: project_id)
|
||||||
|
|
||||||
|
case BDS.Scripting.Capabilities.AppShell.copy_to_clipboard(bookmarklet, []) do
|
||||||
|
true -> Notify.output(dgettext("ui", "Blogmark Bookmarklet"), dgettext("ui", "Bookmarklet copied to clipboard"), "info")
|
||||||
|
_ -> Notify.output(dgettext("ui", "Blogmark Bookmarklet"), dgettext("ui", "Failed to copy bookmarklet to clipboard"), "error")
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("install_cli_tool", _params, socket) do
|
||||||
|
case BDS.UI.SettingsForm.run_action("install_cli") do
|
||||||
|
{:ok, message} -> LiveToast.send_toast(:info, message)
|
||||||
|
{:error, message} -> LiveToast.send_toast(:error, message)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("change_settings_search", %{"query" => query}, socket) do
|
def handle_event("change_settings_search", %{"query" => query}, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -290,7 +311,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
|||||||
query,
|
query,
|
||||||
~w(mcp claude copilot gemini opencode mistral codex agent server)
|
~w(mcp claude copilot gemini opencode mistral codex agent server)
|
||||||
),
|
),
|
||||||
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem)),
|
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem cli)),
|
||||||
supported_languages: @supported_languages,
|
supported_languages: @supported_languages,
|
||||||
protected_categories: ManagedCategories.protected_categories()
|
protected_categories: ManagedCategories.protected_categories()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
|||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
|
|
||||||
alias BDS.AI
|
alias BDS.AI
|
||||||
|
alias BDS.Desktop.ShellLive.Notify
|
||||||
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
|
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
|
||||||
require Logger
|
require Logger
|
||||||
use Gettext, backend: BDS.Gettext
|
use Gettext, backend: BDS.Gettext
|
||||||
@@ -71,6 +72,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
|||||||
|
|
||||||
with {:ok, endpoint} <- endpoint_refresh_attrs(endpoint_key, attrs),
|
with {:ok, endpoint} <- endpoint_refresh_attrs(endpoint_key, attrs),
|
||||||
{:ok, models} <- AI.list_endpoint_models(endpoint) do
|
{:ok, models} <- AI.list_endpoint_models(endpoint) do
|
||||||
|
notify_models_loaded(models)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(
|
||||||
:settings_editor_endpoint_models,
|
:settings_editor_endpoint_models,
|
||||||
@@ -83,16 +86,45 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
|||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
else
|
else
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
|
message = ai_error_message(reason)
|
||||||
|
Notify.alert(dgettext("ui", "Could not load models"), message)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> append_output.(dgettext("ui", "AI Settings"), inspect(reason), nil, "error")
|
|> append_output.(dgettext("ui", "AI Settings"), message, nil, "error")
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec save_ai(term(), term(), term()) :: term()
|
@spec save_ai(term(), term(), term()) :: term()
|
||||||
def save_ai(socket, reload, append_output) do
|
def save_ai(socket, reload, append_output) do
|
||||||
|
do_save_ai(socket, reload, append_output)
|
||||||
|
rescue
|
||||||
|
error ->
|
||||||
|
Logger.error("AI settings save crashed: #{Exception.format(:error, error, __STACKTRACE__)}")
|
||||||
|
surface_ai_error(socket, reload, append_output, error)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_save_ai(socket, reload, append_output) do
|
||||||
attrs = ai_attrs(socket.assigns)
|
attrs = ai_attrs(socket.assigns)
|
||||||
|
|
||||||
|
case save_attrs(attrs) do
|
||||||
|
:ok ->
|
||||||
|
LiveToast.send_toast(:info, dgettext("ui", "AI settings saved."))
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:settings_editor_ai_draft, %{})
|
||||||
|
|> assign(:offline_mode, attrs.offline_mode)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
surface_ai_error(socket, reload, append_output, reason)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The full save pipeline as a pure function so the TUI settings form
|
||||||
|
# (BDS.UI.SettingsForm) drives the exact same writes as the GUI editor.
|
||||||
|
@spec save_attrs(map()) :: :ok | {:error, term()}
|
||||||
|
def save_attrs(attrs) do
|
||||||
with :ok <-
|
with :ok <-
|
||||||
put_endpoint_preferences(
|
put_endpoint_preferences(
|
||||||
:online,
|
:online,
|
||||||
@@ -142,18 +174,40 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
|||||||
attrs.offline_chat_images
|
attrs.offline_chat_images
|
||||||
),
|
),
|
||||||
:ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do
|
:ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do
|
||||||
socket
|
:ok
|
||||||
|> assign(:settings_editor_ai_draft, %{})
|
|
||||||
|> assign(:offline_mode, attrs.offline_mode)
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
else
|
|
||||||
{:error, reason} ->
|
|
||||||
socket
|
|
||||||
|> append_output.(dgettext("ui", "AI Settings"), inspect(reason), nil, "error")
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp notify_models_loaded([]) do
|
||||||
|
LiveToast.send_toast(
|
||||||
|
:info,
|
||||||
|
dgettext(
|
||||||
|
"ui",
|
||||||
|
"The endpoint returned no models. You can still type a model name manually."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp notify_models_loaded(models) do
|
||||||
|
LiveToast.send_toast(
|
||||||
|
:info,
|
||||||
|
dgettext("ui", "Loaded %{count} models.", count: length(models))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp surface_ai_error(socket, reload, append_output, reason) do
|
||||||
|
message = ai_error_message(reason)
|
||||||
|
Notify.alert(dgettext("ui", "Could not save AI settings"), message)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> append_output.(dgettext("ui", "AI Settings"), message, nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ai_error_message(%{__exception__: true} = error), do: Exception.message(error)
|
||||||
|
defp ai_error_message(reason) when is_binary(reason), do: reason
|
||||||
|
defp ai_error_message(reason), do: inspect(reason)
|
||||||
|
|
||||||
@spec reset_ai_prompt(term(), term(), term()) :: term()
|
@spec reset_ai_prompt(term(), term(), term()) :: term()
|
||||||
def reset_ai_prompt(socket, reload, append_output) do
|
def reset_ai_prompt(socket, reload, append_output) do
|
||||||
case EditorSettings.put_global_setting("ai.system_prompt", "") do
|
case EditorSettings.put_global_setting("ai.system_prompt", "") do
|
||||||
@@ -179,7 +233,9 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp ai_attrs(assigns) do
|
defp ai_attrs(assigns) do
|
||||||
draft = Map.get(assigns, :settings_editor_ai_draft, %{})
|
# Merge the stored form with the edit draft so untouched fields (URLs, API
|
||||||
|
# keys) are preserved on save instead of being read as nil and wiped.
|
||||||
|
draft = Map.merge(ai_form(assigns), Map.get(assigns, :settings_editor_ai_draft, %{}))
|
||||||
|
|
||||||
%{
|
%{
|
||||||
online_url: blank_to_nil(Map.get(draft, "online_url")),
|
online_url: blank_to_nil(Map.get(draft, "online_url")),
|
||||||
|
|||||||
@@ -98,7 +98,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Bookmarklet") %></label></div>
|
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Bookmarklet") %></label></div>
|
||||||
<div class="setting-control"><p class="setting-description"><%= dgettext("ui", "Bookmarklet copy support is wired through the desktop runtime and project public URL.") %></p></div>
|
<div class="setting-control">
|
||||||
|
<p class="setting-description"><%= dgettext("ui", "Bookmarklet copy support is wired through the desktop runtime and project public URL.") %></p>
|
||||||
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="copy_blogmark_bookmarklet" phx-target={@myself}><%= dgettext("ui", "Copy Bookmarklet to Clipboard") %></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_project" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div>
|
<div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_project" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div>
|
||||||
@@ -372,11 +375,21 @@
|
|||||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_media_from_files"><%= dgettext("ui", "Rebuild Media From Files") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_media_from_files"><%= dgettext("ui", "Rebuild Media From Files") %></button>
|
||||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_scripts_from_files"><%= dgettext("ui", "Rebuild Scripts From Files") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_scripts_from_files"><%= dgettext("ui", "Rebuild Scripts From Files") %></button>
|
||||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_templates_from_files"><%= dgettext("ui", "Rebuild Templates From Files") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_templates_from_files"><%= dgettext("ui", "Rebuild Templates From Files") %></button>
|
||||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_post_links"><%= dgettext("ui", "Rebuild Links") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_post_links"><%= dgettext("ui", "Rebuild Post Links") %></button>
|
||||||
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_media_links"><%= dgettext("ui", "Rebuild Media Links") %></button>
|
||||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="regenerate_missing_thumbnails"><%= dgettext("ui", "Regenerate Missing Thumbnails") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="regenerate_missing_thumbnails"><%= dgettext("ui", "Regenerate Missing Thumbnails") %></button>
|
||||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_embedding_index"><%= dgettext("ui", "Rebuild Embedding Index") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_embedding_index"><%= dgettext("ui", "Rebuild Embedding Index") %></button>
|
||||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label class="setting-label"><%= dgettext("ui", "Command Line Tool") %></label>
|
||||||
|
<p class="setting-description"><%= dgettext("ui", "Install the bds-cli tool to ~/.local/bin; it uses the same settings and cache database as the app") %></p>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="install_cli_tool" phx-target={@myself}><%= dgettext("ui", "Install CLI Tool") %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ defmodule BDS.Desktop.ShellLive.SocketState do
|
|||||||
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|
||||||
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
||||||
|> assign(:current_tab, current_tab)
|
|> assign(:current_tab, current_tab)
|
||||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
|> assign(:editor_meta, ShellData.editor_meta(task_status, page_language))
|
||||||
|> assign(
|
|> assign(
|
||||||
:status,
|
:status,
|
||||||
ShellData.status_bar(workbench, task_status, dashboard,
|
ShellData.status_bar(workbench, task_status, dashboard,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ defmodule BDS.Desktop.ShellLive.TabActions do
|
|||||||
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
|
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
|
||||||
) do
|
) do
|
||||||
if Workbench.dirty?(workbench, :post, post_id) do
|
if Workbench.dirty?(workbench, :post, post_id) do
|
||||||
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :auto_save)
|
||||||
end
|
end
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
58
lib/bds/events.ex
Normal file
58
lib/bds/events.ex
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
defmodule BDS.Events do
|
||||||
|
@moduledoc """
|
||||||
|
Domain event bus (issue #26, phase 2).
|
||||||
|
|
||||||
|
Contexts broadcast entity mutations on the same `"entity:changed"` topic
|
||||||
|
(and payload shape) that `BDS.CliSync.Watcher` already uses for external
|
||||||
|
CLI writes, so every connected client — LiveView shells and TUI sessions
|
||||||
|
alike — stays synchronized through one subscription regardless of where
|
||||||
|
a change originated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@entity_topic "entity:changed"
|
||||||
|
@settings_topic "settings:changed"
|
||||||
|
|
||||||
|
@type action :: :created | :updated | :deleted
|
||||||
|
|
||||||
|
@doc "Subscribe the calling process to entity and settings changes."
|
||||||
|
@spec subscribe() :: :ok
|
||||||
|
def subscribe do
|
||||||
|
:ok = Phoenix.PubSub.subscribe(BDS.PubSub, @entity_topic)
|
||||||
|
:ok = Phoenix.PubSub.subscribe(BDS.PubSub, @settings_topic)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Broadcast an entity mutation as `{:entity_changed, %{entity: entity,
|
||||||
|
entity_id: id, action: action}}` — the same message the CLI sync watcher
|
||||||
|
emits, so subscribers handle both through one code path.
|
||||||
|
"""
|
||||||
|
@spec entity_changed(String.t(), String.t(), action()) :: :ok
|
||||||
|
def entity_changed(entity, entity_id, action)
|
||||||
|
when is_binary(entity) and is_binary(entity_id) and
|
||||||
|
action in [:created, :updated, :deleted] do
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
BDS.PubSub,
|
||||||
|
@entity_topic,
|
||||||
|
{:entity_changed, %{entity: entity, entity_id: entity_id, action: action}}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Pipe-friendly broadcast: on `{:ok, %{id: id}}` broadcasts `action` for
|
||||||
|
`entity` and returns the result unchanged; any other result passes
|
||||||
|
through untouched.
|
||||||
|
"""
|
||||||
|
@spec broadcast_result(term(), String.t(), action()) :: term()
|
||||||
|
def broadcast_result({:ok, %{id: id}} = result, entity, action) when is_binary(id) do
|
||||||
|
:ok = entity_changed(entity, id, action)
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_result(result, _entity, _action), do: result
|
||||||
|
|
||||||
|
@doc "Broadcast a global setting change as `{:settings_changed, key}`."
|
||||||
|
@spec settings_changed(String.t()) :: :ok
|
||||||
|
def settings_changed(key) when is_binary(key) do
|
||||||
|
Phoenix.PubSub.broadcast(BDS.PubSub, @settings_topic, {:settings_changed, key})
|
||||||
|
end
|
||||||
|
end
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -175,6 +169,7 @@ defmodule BDS.Generation.Data do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
)
|
)
|
||||||
|
|> sort_generation_index_posts()
|
||||||
end
|
end
|
||||||
|
|
||||||
## --- internals -----------------------------------------------------------
|
## --- internals -----------------------------------------------------------
|
||||||
@@ -230,7 +225,7 @@ defmodule BDS.Generation.Data do
|
|||||||
read_snapshot(full_path, fallback_post, &build_post_snapshot/2)
|
read_snapshot(full_path, fallback_post, &build_post_snapshot/2)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_generation_route_posts(
|
defp build_generation_translations(
|
||||||
project_id,
|
project_id,
|
||||||
project_data_dir,
|
project_data_dir,
|
||||||
published_posts,
|
published_posts,
|
||||||
@@ -267,17 +262,7 @@ defmodule BDS.Generation.Data do
|
|||||||
end)
|
end)
|
||||||
|> Map.new(fn {post_id, translations} -> {post_id, Enum.reverse(translations)} end)
|
|> Map.new(fn {post_id, translations} -> {post_id, Enum.reverse(translations)} end)
|
||||||
|
|
||||||
route_posts =
|
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
|
||||||
@@ -356,31 +341,6 @@ defmodule BDS.Generation.Data do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_published_translation_variant(post, translation) do
|
|
||||||
%{
|
|
||||||
id: translation.id,
|
|
||||||
project_id: post.project_id,
|
|
||||||
title: translation.title,
|
|
||||||
slug: "#{post.slug}.#{translation.language}",
|
|
||||||
excerpt: translation.excerpt,
|
|
||||||
content: nil,
|
|
||||||
status: :published,
|
|
||||||
author: post.author,
|
|
||||||
created_at: post.created_at,
|
|
||||||
updated_at: translation.updated_at,
|
|
||||||
published_at: translation.published_at || post.published_at,
|
|
||||||
file_path: translation.file_path,
|
|
||||||
tags: post.tags,
|
|
||||||
categories: post.categories,
|
|
||||||
template_slug: post.template_slug,
|
|
||||||
language: translation.language,
|
|
||||||
do_not_translate: post.do_not_translate,
|
|
||||||
translation_source_slug: post.slug,
|
|
||||||
translation_canonical_language: post.language,
|
|
||||||
translation_file_path: translation.file_path
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_localized_subtree_variant(post, translation) do
|
defp build_localized_subtree_variant(post, translation) do
|
||||||
%{
|
%{
|
||||||
post
|
post
|
||||||
@@ -398,4 +358,17 @@ defmodule BDS.Generation.Data do
|
|||||||
defp append_generation_index(index, field, key, post) do
|
defp append_generation_index(index, field, key, post) do
|
||||||
update_in(index[field], fn grouped -> Map.update(grouped, key, [post], &[post | &1]) end)
|
update_in(index[field], fn grouped -> Map.update(grouped, key, [post], &[post | &1]) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Archive listings (categories, tags, dates) must render newest first. The
|
||||||
|
# index is built by prepending, so each grouped list is sorted explicitly by
|
||||||
|
# creation date (descending) to guarantee a stable newest-first order.
|
||||||
|
defp sort_generation_index_posts(index) do
|
||||||
|
Map.new(index, fn {field, grouped} ->
|
||||||
|
{field, Map.new(grouped, fn {key, posts} -> {key, sort_index_posts_desc(posts)} end)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sort_index_posts_desc(posts) do
|
||||||
|
Enum.sort_by(posts, &{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ defmodule BDS.Generation.Outputs do
|
|||||||
import BDS.Generation.Renderers
|
import BDS.Generation.Renderers
|
||||||
import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1]
|
import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1]
|
||||||
|
|
||||||
|
alias BDS.Rendering.RenderContext
|
||||||
|
alias BDS.Rendering.Metadata, as: RenderMetadata
|
||||||
alias BDS.Rendering.TemplateSelection
|
alias BDS.Rendering.TemplateSelection
|
||||||
|
|
||||||
|
@type output_callback :: ({String.t(), iodata()} -> any()) | nil
|
||||||
|
@type render_target :: RenderContext.t() | String.t()
|
||||||
|
|
||||||
@spec additional_languages(map()) :: [String.t()]
|
@spec additional_languages(map()) :: [String.t()]
|
||||||
def additional_languages(plan) do
|
def additional_languages(plan) do
|
||||||
Enum.reject(plan.blog_languages, &(&1 == plan.language))
|
Enum.reject(plan.blog_languages, &(&1 == plan.language))
|
||||||
@@ -17,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()
|
||||||
]
|
]
|
||||||
@@ -149,16 +144,17 @@ defmodule BDS.Generation.Outputs do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec build_archive_outputs(map(), map(), map()) :: [{String.t(), iodata()}]
|
@spec build_archive_outputs(map(), map(), map(), output_callback()) :: [{String.t(), iodata()}]
|
||||||
def build_archive_outputs(plan, post_index, localized_post_indexes) do
|
def build_archive_outputs(plan, post_index, localized_post_indexes, on_output \\ nil) do
|
||||||
category_outputs =
|
category_outputs =
|
||||||
if :category in plan.sections do
|
if :category in plan.sections do
|
||||||
build_category_outputs(plan, post_index.posts_by_category, [plan.language]) ++
|
build_category_outputs(plan, post_index.posts_by_category, [plan.language], on_output) ++
|
||||||
Enum.flat_map(additional_languages(plan), fn language ->
|
Enum.flat_map(additional_languages(plan), fn language ->
|
||||||
build_category_outputs(
|
build_category_outputs(
|
||||||
plan,
|
plan,
|
||||||
Map.get(localized_post_indexes, language, %{posts_by_category: %{}}).posts_by_category,
|
Map.get(localized_post_indexes, language, %{posts_by_category: %{}}).posts_by_category,
|
||||||
[language]
|
[language],
|
||||||
|
on_output
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
@@ -167,12 +163,13 @@ defmodule BDS.Generation.Outputs do
|
|||||||
|
|
||||||
tag_outputs =
|
tag_outputs =
|
||||||
if :tag in plan.sections do
|
if :tag in plan.sections do
|
||||||
build_tag_outputs(plan, post_index.posts_by_tag, [plan.language]) ++
|
build_tag_outputs(plan, post_index.posts_by_tag, [plan.language], on_output) ++
|
||||||
Enum.flat_map(additional_languages(plan), fn language ->
|
Enum.flat_map(additional_languages(plan), fn language ->
|
||||||
build_tag_outputs(
|
build_tag_outputs(
|
||||||
plan,
|
plan,
|
||||||
Map.get(localized_post_indexes, language, %{posts_by_tag: %{}}).posts_by_tag,
|
Map.get(localized_post_indexes, language, %{posts_by_tag: %{}}).posts_by_tag,
|
||||||
[language]
|
[language],
|
||||||
|
on_output
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
@@ -181,7 +178,7 @@ defmodule BDS.Generation.Outputs do
|
|||||||
|
|
||||||
date_outputs =
|
date_outputs =
|
||||||
if :date in plan.sections do
|
if :date in plan.sections do
|
||||||
build_date_outputs(plan, post_index, [plan.language]) ++
|
build_date_outputs(plan, post_index, [plan.language], on_output) ++
|
||||||
Enum.flat_map(additional_languages(plan), fn language ->
|
Enum.flat_map(additional_languages(plan), fn language ->
|
||||||
build_date_outputs(
|
build_date_outputs(
|
||||||
plan,
|
plan,
|
||||||
@@ -190,7 +187,8 @@ defmodule BDS.Generation.Outputs do
|
|||||||
language,
|
language,
|
||||||
%{posts_by_year: %{}, posts_by_year_month: %{}, posts_by_year_month_day: %{}}
|
%{posts_by_year: %{}, posts_by_year_month: %{}, posts_by_year_month_day: %{}}
|
||||||
),
|
),
|
||||||
[language]
|
[language],
|
||||||
|
on_output
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
@@ -200,11 +198,14 @@ defmodule BDS.Generation.Outputs do
|
|||||||
category_outputs ++ tag_outputs ++ date_outputs
|
category_outputs ++ tag_outputs ++ date_outputs
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec build_category_outputs(map(), map(), [String.t()]) :: [{String.t(), iodata()}]
|
@spec build_category_outputs(map(), map(), [String.t()], output_callback()) :: [
|
||||||
def build_category_outputs(plan, posts_by_category, languages) do
|
{String.t(), iodata()}
|
||||||
Enum.flat_map(posts_by_category, fn {category, posts} ->
|
]
|
||||||
|
def build_category_outputs(plan, posts_by_category, languages, on_output \\ nil) do
|
||||||
|
concurrent_flat_map(posts_by_category, fn {category, posts} ->
|
||||||
paginated_posts = Enum.chunk_every(posts, max(plan.max_posts_per_page, 1))
|
paginated_posts = Enum.chunk_every(posts, max(plan.max_posts_per_page, 1))
|
||||||
category_slug = archive_route_segment(category)
|
category_slug = archive_route_segment(category)
|
||||||
|
category_title = RenderMetadata.category_display_name(plan.category_settings, category)
|
||||||
total_pages = length(paginated_posts)
|
total_pages = length(paginated_posts)
|
||||||
total_items = length(posts)
|
total_items = length(posts)
|
||||||
|
|
||||||
@@ -240,36 +241,48 @@ defmodule BDS.Generation.Outputs do
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
output =
|
||||||
archive_path(
|
{
|
||||||
route_language(plan.language, language),
|
archive_path(
|
||||||
["category", category_slug],
|
route_language(plan.language, language),
|
||||||
page_number
|
["category", category_slug],
|
||||||
),
|
page_number
|
||||||
render_archive_page(plan, category, page_posts, language, "category", pagination)
|
),
|
||||||
}
|
render_archive_page(plan, category_title, page_posts, language, "category", pagination)
|
||||||
|
}
|
||||||
|
|
||||||
|
report_output(output, on_output)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec build_tag_outputs(map(), map(), [String.t()]) :: [{String.t(), iodata()}]
|
@spec build_tag_outputs(map(), map(), [String.t()], output_callback()) :: [
|
||||||
def build_tag_outputs(plan, posts_by_tag, languages) do
|
{String.t(), iodata()}
|
||||||
Enum.flat_map(posts_by_tag, fn {tag, posts} ->
|
]
|
||||||
|
def build_tag_outputs(plan, posts_by_tag, languages, on_output \\ nil) do
|
||||||
|
concurrent_flat_map(posts_by_tag, fn {tag, posts} ->
|
||||||
tag_slug = archive_route_segment(tag)
|
tag_slug = archive_route_segment(tag)
|
||||||
|
|
||||||
build_paginated_archive_outputs(plan, languages, ["tag", tag_slug], posts, fn page_posts,
|
build_paginated_archive_outputs(
|
||||||
language,
|
plan,
|
||||||
pagination ->
|
languages,
|
||||||
render_archive_page(plan, tag, page_posts, language, "tag", pagination)
|
["tag", tag_slug],
|
||||||
end)
|
posts,
|
||||||
|
fn page_posts, language, pagination ->
|
||||||
|
render_archive_page(plan, tag, page_posts, language, "tag", pagination)
|
||||||
|
end,
|
||||||
|
on_output
|
||||||
|
)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec build_date_outputs(map(), map(), [String.t()]) :: [{String.t(), iodata()}]
|
@spec build_date_outputs(map(), map(), [String.t()], output_callback()) :: [
|
||||||
def build_date_outputs(plan, post_index, languages) do
|
{String.t(), iodata()}
|
||||||
|
]
|
||||||
|
def build_date_outputs(plan, post_index, languages, on_output \\ nil) do
|
||||||
year_outputs =
|
year_outputs =
|
||||||
Enum.flat_map(post_index.posts_by_year, fn {year, posts} ->
|
concurrent_flat_map(post_index.posts_by_year, fn {year, posts} ->
|
||||||
build_paginated_archive_outputs(
|
build_paginated_archive_outputs(
|
||||||
plan,
|
plan,
|
||||||
languages,
|
languages,
|
||||||
@@ -284,127 +297,154 @@ defmodule BDS.Generation.Outputs do
|
|||||||
language,
|
language,
|
||||||
pagination
|
pagination
|
||||||
)
|
)
|
||||||
end
|
end,
|
||||||
|
on_output
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
month_outputs =
|
month_outputs =
|
||||||
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
|
concurrent_flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
|
||||||
[year, month] = String.split(year_month, "/", parts: 2)
|
[year, month] = String.split(year_month, "/", parts: 2)
|
||||||
|
|
||||||
build_paginated_archive_outputs(plan, languages, [year, month], posts, fn page_posts,
|
build_paginated_archive_outputs(
|
||||||
language,
|
plan,
|
||||||
pagination ->
|
languages,
|
||||||
render_date_archive_page(
|
[year, month],
|
||||||
plan,
|
posts,
|
||||||
"#{year}-#{month}",
|
fn page_posts, language, pagination ->
|
||||||
%{kind: "month", year: String.to_integer(year), month: String.to_integer(month)},
|
render_date_archive_page(
|
||||||
page_posts,
|
plan,
|
||||||
language,
|
"#{year}-#{month}",
|
||||||
pagination
|
%{kind: "month", year: String.to_integer(year), month: String.to_integer(month)},
|
||||||
)
|
page_posts,
|
||||||
end)
|
language,
|
||||||
|
pagination
|
||||||
|
)
|
||||||
|
end,
|
||||||
|
on_output
|
||||||
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
day_outputs =
|
day_outputs =
|
||||||
Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} ->
|
concurrent_flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} ->
|
||||||
[year, month, day] = String.split(year_month_day, "/", parts: 3)
|
[year, month, day] = String.split(year_month_day, "/", parts: 3)
|
||||||
|
|
||||||
build_paginated_archive_outputs(plan, languages, [year, month, day], posts, fn page_posts,
|
build_paginated_archive_outputs(
|
||||||
language,
|
plan,
|
||||||
pagination ->
|
languages,
|
||||||
render_date_archive_page(
|
[year, month, day],
|
||||||
plan,
|
posts,
|
||||||
"#{year}-#{month}-#{day}",
|
fn page_posts, language, pagination ->
|
||||||
%{
|
render_date_archive_page(
|
||||||
kind: "day",
|
plan,
|
||||||
year: String.to_integer(year),
|
"#{year}-#{month}-#{day}",
|
||||||
month: String.to_integer(month),
|
%{
|
||||||
day: String.to_integer(day)
|
kind: "day",
|
||||||
},
|
year: String.to_integer(year),
|
||||||
page_posts,
|
month: String.to_integer(month),
|
||||||
language,
|
day: String.to_integer(day)
|
||||||
pagination
|
},
|
||||||
)
|
page_posts,
|
||||||
end)
|
language,
|
||||||
|
pagination
|
||||||
|
)
|
||||||
|
end,
|
||||||
|
on_output
|
||||||
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
year_outputs ++ month_outputs ++ day_outputs
|
year_outputs ++ month_outputs ++ day_outputs
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec build_core_outputs(map(), [map()], map()) :: [{String.t(), iodata()}]
|
@spec build_core_outputs(map(), [map()], map(), output_callback()) :: [{String.t(), iodata()}]
|
||||||
def build_core_outputs(plan, published_posts, localized_posts_by_language) do
|
def build_core_outputs(plan, published_posts, localized_posts_by_language, on_output \\ nil) do
|
||||||
language = plan.language
|
language = plan.language
|
||||||
additional_languages = Enum.reject(plan.blog_languages, &(&1 == language))
|
additional_languages = Enum.reject(plan.blog_languages, &(&1 == language))
|
||||||
main_posts = build_list_posts(plan.base_url, published_posts, nil)
|
main_posts = build_list_posts(plan, published_posts, nil)
|
||||||
|
|
||||||
build_root_outputs(plan, language, main_posts) ++
|
main_static_outputs =
|
||||||
[
|
[
|
||||||
{"404.html", render_not_found_output(plan, language)},
|
{"404.html", render_not_found_output(plan, language)},
|
||||||
{"feed.xml", render_feed(plan, language, published_posts)},
|
{"rss.xml", render_feed(plan, language, published_posts)},
|
||||||
{"atom.xml", render_atom(plan, language, published_posts)},
|
{"atom.xml", render_atom(plan, language, published_posts)},
|
||||||
{"calendar.json", render_calendar(published_posts)}
|
{"calendar.json", render_calendar(published_posts)}
|
||||||
] ++
|
]
|
||||||
|
|> Enum.map(&report_output(&1, on_output))
|
||||||
|
|
||||||
|
build_root_outputs(plan, language, main_posts, on_output) ++
|
||||||
|
main_static_outputs ++
|
||||||
Enum.flat_map(additional_languages, fn localized_language ->
|
Enum.flat_map(additional_languages, fn localized_language ->
|
||||||
localized_prefix = route_language(plan.language, localized_language)
|
localized_prefix = route_language(plan.language, localized_language)
|
||||||
localized_source_posts = Map.get(localized_posts_by_language, localized_language, [])
|
localized_source_posts = Map.get(localized_posts_by_language, localized_language, [])
|
||||||
|
|
||||||
localized_posts =
|
localized_posts =
|
||||||
build_list_posts(plan.base_url, localized_source_posts, localized_prefix)
|
build_list_posts(plan, localized_source_posts, localized_prefix)
|
||||||
|
|
||||||
build_root_outputs(plan, localized_language, localized_posts) ++
|
# `build_root_outputs` is called without `on_output` here because the
|
||||||
[
|
# combined list (roots + static files) is reported exactly once by the
|
||||||
{Path.join(localized_language, "404.html"),
|
# `Enum.map(&report_output/2)` below. Passing `on_output` to both would
|
||||||
render_not_found_output(plan, localized_language)},
|
# report (and, for streaming callers, write and count) each localized
|
||||||
{Path.join(localized_language, "feed.xml"),
|
# root page twice.
|
||||||
render_feed(plan, localized_language, localized_source_posts)},
|
(build_root_outputs(plan, localized_language, localized_posts) ++
|
||||||
{Path.join(localized_language, "atom.xml"),
|
[
|
||||||
render_atom(plan, localized_language, localized_source_posts)}
|
{Path.join(localized_language, "404.html"),
|
||||||
]
|
render_not_found_output(plan, localized_language)},
|
||||||
|
{Path.join(localized_language, "rss.xml"),
|
||||||
|
render_feed(plan, localized_language, localized_source_posts)},
|
||||||
|
{Path.join(localized_language, "atom.xml"),
|
||||||
|
render_atom(plan, localized_language, localized_source_posts)}
|
||||||
|
])
|
||||||
|
|> Enum.map(&report_output(&1, on_output))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec build_page_outputs(String.t(), String.t(), [map()], map(), map()) :: [
|
@spec build_page_outputs(render_target(), String.t(), [map()], map(), map(), output_callback()) ::
|
||||||
{String.t(), iodata()}
|
[
|
||||||
]
|
{String.t(), iodata()}
|
||||||
|
]
|
||||||
def build_page_outputs(
|
def build_page_outputs(
|
||||||
project_id,
|
render_target,
|
||||||
main_language,
|
main_language,
|
||||||
published_posts,
|
published_posts,
|
||||||
translations_by_post_language,
|
translations_by_post_language,
|
||||||
localized_posts_by_language
|
localized_posts_by_language,
|
||||||
|
on_output \\ nil
|
||||||
) do
|
) do
|
||||||
page_outputs =
|
page_outputs =
|
||||||
published_posts
|
published_posts
|
||||||
|> Enum.filter(&("page" in (&1.categories || [])))
|
|> Enum.filter(&("page" in (&1.categories || [])))
|
||||||
|> Enum.map(fn post ->
|
|> concurrent_map(fn post ->
|
||||||
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
|
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
|
||||||
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
|
render_language = effective_render_language(canonical_variant.language, main_language)
|
||||||
|
body = load_body(render_target, canonical_variant.file_path, canonical_variant.content)
|
||||||
|
|
||||||
effective_slug = effective_template_slug(project_id, post)
|
effective_slug = effective_template_slug(render_target, post)
|
||||||
|
|
||||||
{page_output_path(post.slug, nil),
|
output =
|
||||||
render_post_output(
|
{page_output_path(post.slug, nil),
|
||||||
project_id,
|
render_post_output(
|
||||||
effective_slug,
|
render_target,
|
||||||
%{
|
effective_slug,
|
||||||
id: canonical_variant.id,
|
%{
|
||||||
title: canonical_variant.title,
|
id: canonical_variant.id,
|
||||||
content: body,
|
title: canonical_variant.title,
|
||||||
slug: post.slug,
|
content: body,
|
||||||
language: canonical_variant.language,
|
slug: post.slug,
|
||||||
excerpt: canonical_variant.excerpt,
|
language: render_language,
|
||||||
_post_record: canonical_variant
|
excerpt: canonical_variant.excerpt,
|
||||||
},
|
_post_record: canonical_variant
|
||||||
fn ->
|
},
|
||||||
render_post_page(
|
fn ->
|
||||||
canonical_variant.title,
|
render_post_page(
|
||||||
body,
|
canonical_variant.title,
|
||||||
post.slug,
|
body,
|
||||||
canonical_variant.language
|
post.slug,
|
||||||
)
|
render_language
|
||||||
end
|
)
|
||||||
)}
|
end
|
||||||
|
)}
|
||||||
|
|
||||||
|
report_output(output, on_output)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
translation_page_outputs =
|
translation_page_outputs =
|
||||||
@@ -412,67 +452,89 @@ defmodule BDS.Generation.Outputs do
|
|||||||
|> Enum.flat_map(fn {language, posts} ->
|
|> Enum.flat_map(fn {language, posts} ->
|
||||||
posts
|
posts
|
||||||
|> Enum.filter(&("page" in (&1.categories || [])))
|
|> Enum.filter(&("page" in (&1.categories || [])))
|
||||||
|> Enum.map(fn post ->
|
|> concurrent_map(fn post ->
|
||||||
body = load_body(project_id, post.file_path, post.content)
|
render_language = effective_render_language(post.language, language)
|
||||||
|
body = load_body(render_target, post.file_path, post.content)
|
||||||
|
|
||||||
effective_slug = effective_template_slug(project_id, post)
|
effective_slug = effective_template_slug(render_target, post)
|
||||||
|
|
||||||
{page_output_path(post.slug, language),
|
output =
|
||||||
render_post_output(
|
{page_output_path(post.slug, language),
|
||||||
project_id,
|
render_post_output(
|
||||||
effective_slug,
|
render_target,
|
||||||
%{
|
effective_slug,
|
||||||
id: post.id,
|
%{
|
||||||
title: post.title,
|
id: post.id,
|
||||||
content: body,
|
title: post.title,
|
||||||
slug: post.slug,
|
content: body,
|
||||||
language: post.language,
|
slug: post.slug,
|
||||||
excerpt: post.excerpt,
|
language: render_language,
|
||||||
_post_record: post
|
excerpt: post.excerpt,
|
||||||
},
|
_post_record: post
|
||||||
fn -> render_post_page(post.title, body, post.slug, post.language) end
|
},
|
||||||
)}
|
fn -> render_post_page(post.title, body, post.slug, render_language) end
|
||||||
|
)}
|
||||||
|
|
||||||
|
report_output(output, on_output)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
page_outputs ++ translation_page_outputs
|
page_outputs ++ translation_page_outputs
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec build_root_outputs(map(), String.t(), [map()]) :: [{String.t(), iodata()}]
|
@spec build_root_outputs(map(), String.t(), [map()], output_callback()) :: [
|
||||||
def build_root_outputs(plan, language, posts) do
|
{String.t(), iodata()}
|
||||||
|
]
|
||||||
|
def build_root_outputs(plan, language, posts, on_output \\ nil) do
|
||||||
total_items = length(posts)
|
total_items = length(posts)
|
||||||
total_pages = page_count(total_items, plan.max_posts_per_page)
|
total_pages = page_count(total_items, plan.max_posts_per_page)
|
||||||
|
|
||||||
posts
|
posts
|
||||||
|> paginate_posts(plan.max_posts_per_page)
|
|> paginate_posts(plan.max_posts_per_page)
|
||||||
|> Enum.with_index(1)
|
|> Enum.with_index(1)
|
||||||
|> Enum.map(fn {page_posts, page_number} ->
|
|> concurrent_map(fn {page_posts, page_number} ->
|
||||||
route_language = route_language(plan.language, language)
|
route_language = route_language(plan.language, language)
|
||||||
|
|
||||||
{root_output_path(route_language, page_number),
|
output =
|
||||||
render_list_output(
|
{root_output_path(route_language, page_number),
|
||||||
plan,
|
render_list_output(
|
||||||
language,
|
plan,
|
||||||
plan.project_name,
|
language,
|
||||||
page_posts,
|
blog_page_title(plan),
|
||||||
%{kind: "core"},
|
page_posts,
|
||||||
pagination_for_page(
|
%{kind: "core"},
|
||||||
page_number,
|
pagination_for_page(
|
||||||
total_pages,
|
page_number,
|
||||||
total_items,
|
total_pages,
|
||||||
plan.max_posts_per_page,
|
total_items,
|
||||||
route_language,
|
plan.max_posts_per_page,
|
||||||
[]
|
route_language,
|
||||||
),
|
[]
|
||||||
fn -> render_home(plan, language) end
|
),
|
||||||
)}
|
fn -> render_home(plan, language) end
|
||||||
|
)}
|
||||||
|
|
||||||
|
report_output(output, on_output)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec build_paginated_archive_outputs(map(), [String.t()], [String.t()], [map()], (... ->
|
@spec build_paginated_archive_outputs(
|
||||||
iodata())) ::
|
map(),
|
||||||
|
[String.t()],
|
||||||
|
[String.t()],
|
||||||
|
[map()],
|
||||||
|
(... -> iodata()),
|
||||||
|
output_callback()
|
||||||
|
) ::
|
||||||
[{String.t(), iodata()}]
|
[{String.t(), iodata()}]
|
||||||
def build_paginated_archive_outputs(plan, languages, segments, posts, render_fun) do
|
def build_paginated_archive_outputs(
|
||||||
|
plan,
|
||||||
|
languages,
|
||||||
|
segments,
|
||||||
|
posts,
|
||||||
|
render_fun,
|
||||||
|
on_output \\ nil
|
||||||
|
) do
|
||||||
total_items = length(posts)
|
total_items = length(posts)
|
||||||
total_pages = page_count(total_items, plan.max_posts_per_page)
|
total_pages = page_count(total_items, plan.max_posts_per_page)
|
||||||
|
|
||||||
@@ -483,104 +545,155 @@ defmodule BDS.Generation.Outputs do
|
|||||||
Enum.map(languages, fn language ->
|
Enum.map(languages, fn language ->
|
||||||
route_language = route_language(plan.language, language)
|
route_language = route_language(plan.language, language)
|
||||||
|
|
||||||
{archive_path(route_language, segments, page_number),
|
output =
|
||||||
render_fun.(
|
{archive_path(route_language, segments, page_number),
|
||||||
page_posts,
|
render_fun.(
|
||||||
language,
|
page_posts,
|
||||||
pagination_for_page(
|
language,
|
||||||
page_number,
|
pagination_for_page(
|
||||||
total_pages,
|
page_number,
|
||||||
total_items,
|
total_pages,
|
||||||
plan.max_posts_per_page,
|
total_items,
|
||||||
route_language,
|
plan.max_posts_per_page,
|
||||||
segments
|
route_language,
|
||||||
)
|
segments
|
||||||
)}
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
report_output(output, on_output)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec build_single_outputs(String.t(), String.t(), [map()], map(), map()) :: [
|
@spec build_single_outputs(render_target(), String.t(), [map()], map(), map(), output_callback()) ::
|
||||||
{String.t(), iodata()}
|
[
|
||||||
]
|
{String.t(), iodata()}
|
||||||
|
]
|
||||||
def build_single_outputs(
|
def build_single_outputs(
|
||||||
project_id,
|
render_target,
|
||||||
main_language,
|
main_language,
|
||||||
published_posts,
|
published_posts,
|
||||||
translations_by_post_language,
|
translations_by_post_language,
|
||||||
localized_posts_by_language
|
localized_posts_by_language,
|
||||||
|
on_output \\ nil
|
||||||
) do
|
) do
|
||||||
post_outputs =
|
post_outputs =
|
||||||
Enum.map(published_posts, fn post ->
|
concurrent_map(published_posts, fn post ->
|
||||||
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
|
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
|
||||||
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
|
render_language = effective_render_language(canonical_variant.language, main_language)
|
||||||
|
body = load_body(render_target, canonical_variant.file_path, canonical_variant.content)
|
||||||
|
|
||||||
effective_slug = effective_template_slug(project_id, post)
|
effective_slug = effective_template_slug(render_target, post)
|
||||||
|
|
||||||
{post_output_path(post),
|
output =
|
||||||
render_post_output(
|
{post_output_path(post),
|
||||||
project_id,
|
render_post_output(
|
||||||
effective_slug,
|
render_target,
|
||||||
%{
|
effective_slug,
|
||||||
id: canonical_variant.id,
|
%{
|
||||||
title: canonical_variant.title,
|
id: canonical_variant.id,
|
||||||
content: body,
|
title: canonical_variant.title,
|
||||||
slug: post.slug,
|
content: body,
|
||||||
language: canonical_variant.language,
|
slug: post.slug,
|
||||||
excerpt: canonical_variant.excerpt,
|
language: render_language,
|
||||||
_post_record: canonical_variant
|
excerpt: canonical_variant.excerpt,
|
||||||
},
|
_post_record: canonical_variant
|
||||||
fn ->
|
},
|
||||||
render_post_page(
|
fn ->
|
||||||
canonical_variant.title,
|
render_post_page(
|
||||||
body,
|
canonical_variant.title,
|
||||||
post.slug,
|
body,
|
||||||
canonical_variant.language
|
post.slug,
|
||||||
)
|
render_language
|
||||||
end
|
)
|
||||||
)}
|
end
|
||||||
|
)}
|
||||||
|
|
||||||
|
report_output(output, on_output)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
translation_outputs =
|
translation_outputs =
|
||||||
localized_posts_by_language
|
localized_posts_by_language
|
||||||
|> Enum.flat_map(fn {language, posts} ->
|
|> Enum.flat_map(fn {language, posts} ->
|
||||||
Enum.map(posts, fn post ->
|
concurrent_map(posts, fn post ->
|
||||||
body = load_body(project_id, post.file_path, post.content)
|
render_language = effective_render_language(post.language, language)
|
||||||
|
body = load_body(render_target, post.file_path, post.content)
|
||||||
|
|
||||||
effective_slug = effective_template_slug(project_id, post)
|
effective_slug = effective_template_slug(render_target, post)
|
||||||
|
|
||||||
{post_output_path(post, language),
|
output =
|
||||||
render_post_output(
|
{post_output_path(post, language),
|
||||||
project_id,
|
render_post_output(
|
||||||
effective_slug,
|
render_target,
|
||||||
%{
|
effective_slug,
|
||||||
id: post.id,
|
%{
|
||||||
title: post.title,
|
id: post.id,
|
||||||
content: body,
|
title: post.title,
|
||||||
slug: post.slug,
|
content: body,
|
||||||
language: post.language,
|
slug: post.slug,
|
||||||
excerpt: post.excerpt,
|
language: render_language,
|
||||||
_post_record: post
|
excerpt: post.excerpt,
|
||||||
},
|
_post_record: post
|
||||||
fn -> render_post_page(post.title, body, post.slug, post.language) end
|
},
|
||||||
)}
|
fn -> render_post_page(post.title, body, post.slug, render_language) end
|
||||||
|
)}
|
||||||
|
|
||||||
|
report_output(output, on_output)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
post_outputs ++ translation_outputs
|
post_outputs ++ translation_outputs
|
||||||
end
|
end
|
||||||
|
|
||||||
defp effective_template_slug(project_id, post) do
|
defp effective_template_slug(render_target, post) do
|
||||||
slug = Map.get(post, :template_slug)
|
slug = Map.get(post, :template_slug)
|
||||||
|
|
||||||
if is_binary(slug) and slug != "" do
|
if is_binary(slug) and slug != "" do
|
||||||
slug
|
slug
|
||||||
else
|
else
|
||||||
TemplateSelection.resolve_post_template_slug(
|
TemplateSelection.resolve_post_template_slug(
|
||||||
project_id,
|
render_target,
|
||||||
Map.get(post, :tags) || [],
|
Map.get(post, :tags) || [],
|
||||||
Map.get(post, :categories) || []
|
Map.get(post, :categories) || []
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp effective_render_language(language, _fallback_language)
|
||||||
|
when is_binary(language) and language != "" do
|
||||||
|
language
|
||||||
|
end
|
||||||
|
|
||||||
|
defp effective_render_language(_language, fallback_language)
|
||||||
|
when is_binary(fallback_language) and fallback_language != "" do
|
||||||
|
fallback_language
|
||||||
|
end
|
||||||
|
|
||||||
|
defp effective_render_language(_language, _fallback_language), do: "en"
|
||||||
|
|
||||||
|
defp report_output(output, nil), do: output
|
||||||
|
|
||||||
|
defp report_output(output, on_output) when is_function(on_output, 1) do
|
||||||
|
on_output.(output)
|
||||||
|
output
|
||||||
|
end
|
||||||
|
|
||||||
|
# Render pages on all cores, like the old app's worker threads — safe because
|
||||||
|
# the render context is immutable data and the output callback only touches
|
||||||
|
# cross-process-safe state (file writes, ETS, counters). Order is preserved.
|
||||||
|
defp concurrent_map(enum, fun) do
|
||||||
|
enum
|
||||||
|
|> Task.async_stream(fun,
|
||||||
|
timeout: :infinity,
|
||||||
|
max_concurrency: System.schedulers_online(),
|
||||||
|
ordered: true
|
||||||
|
)
|
||||||
|
|> Enum.map(fn {:ok, value} -> value end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp concurrent_flat_map(enum, fun) do
|
||||||
|
enum
|
||||||
|
|> concurrent_map(fun)
|
||||||
|
|> Enum.concat()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ defmodule BDS.Generation.Paths do
|
|||||||
|
|
||||||
@spec sitemap_route_output?(String.t()) :: boolean()
|
@spec sitemap_route_output?(String.t()) :: boolean()
|
||||||
def sitemap_route_output?("404.html"), do: false
|
def sitemap_route_output?("404.html"), do: false
|
||||||
def sitemap_route_output?("feed.xml"), do: false
|
def sitemap_route_output?("rss.xml"), do: false
|
||||||
def sitemap_route_output?("atom.xml"), do: false
|
def sitemap_route_output?("atom.xml"), do: false
|
||||||
def sitemap_route_output?("calendar.json"), do: false
|
def sitemap_route_output?("calendar.json"), do: false
|
||||||
def sitemap_route_output?(relative_path), do: String.ends_with?(relative_path, ".html")
|
def sitemap_route_output?(relative_path), do: String.ends_with?(relative_path, ".html")
|
||||||
|
|||||||
@@ -4,19 +4,23 @@ defmodule BDS.Generation.Renderers do
|
|||||||
alias BDS.Generation.Paths
|
alias BDS.Generation.Paths
|
||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.Rendering
|
alias BDS.Rendering
|
||||||
|
alias BDS.Rendering.Metadata, as: RenderMetadata
|
||||||
|
alias BDS.Rendering.RenderContext
|
||||||
|
|
||||||
@doc "Render the home page (HTML) using the project's template engine."
|
@doc "Render the home page (HTML) using the project's template engine."
|
||||||
@spec render_home(map(), String.t() | nil) :: String.t()
|
@spec render_home(map(), String.t() | nil) :: String.t()
|
||||||
def render_home(plan, language) do
|
def render_home(plan, language) do
|
||||||
|
title = blog_page_title(plan)
|
||||||
|
|
||||||
[
|
[
|
||||||
"<html>",
|
"<html>",
|
||||||
"<head><title>",
|
"<head><title>",
|
||||||
plan.project_name,
|
title,
|
||||||
"</title></head>",
|
"</title></head>",
|
||||||
"<body data-language=\"",
|
"<body data-language=\"",
|
||||||
to_string(language),
|
to_string(language),
|
||||||
"\"><main><h1>",
|
"\"><main><h1>",
|
||||||
plan.project_name,
|
title,
|
||||||
"</h1></main></body>",
|
"</h1></main></body>",
|
||||||
"</html>"
|
"</html>"
|
||||||
]
|
]
|
||||||
@@ -43,30 +47,18 @@ defmodule BDS.Generation.Renderers do
|
|||||||
|> IO.iodata_to_binary()
|
|> IO.iodata_to_binary()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Render an archive page (category, tag, year) with pagination."
|
@doc "Render an archive page (category, tag) with pagination."
|
||||||
@spec render_archive_page(map(), String.t(), [map()], String.t() | nil, String.t(), map()) ::
|
@spec render_archive_page(map(), String.t(), [map()], String.t() | nil, String.t(), map()) ::
|
||||||
String.t()
|
String.t()
|
||||||
def render_archive_page(plan, title, posts, language, kind, pagination) do
|
def render_archive_page(plan, title, posts, language, kind, pagination) do
|
||||||
fallback = fn -> render_inline_list_page(title, posts, language, kind) end
|
render_archive_list_page(
|
||||||
|
|
||||||
render_list_output(
|
|
||||||
plan,
|
plan,
|
||||||
language,
|
|
||||||
title,
|
title,
|
||||||
Enum.map(posts, fn post ->
|
|
||||||
%{
|
|
||||||
id: post.id,
|
|
||||||
slug: post.slug,
|
|
||||||
title: post.title,
|
|
||||||
href: "#",
|
|
||||||
excerpt: post.excerpt,
|
|
||||||
content: nil,
|
|
||||||
language: post.language
|
|
||||||
}
|
|
||||||
end),
|
|
||||||
%{kind: kind, name: title},
|
%{kind: kind, name: title},
|
||||||
pagination,
|
posts,
|
||||||
fallback
|
language,
|
||||||
|
kind,
|
||||||
|
pagination
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -74,13 +66,20 @@ defmodule BDS.Generation.Renderers do
|
|||||||
@spec render_date_archive_page(map(), String.t(), map(), [map()], String.t() | nil, map()) ::
|
@spec render_date_archive_page(map(), String.t(), map(), [map()], String.t() | nil, map()) ::
|
||||||
String.t()
|
String.t()
|
||||||
def render_date_archive_page(plan, label, archive_context, posts, language, pagination) do
|
def render_date_archive_page(plan, label, archive_context, posts, language, pagination) do
|
||||||
fallback = fn -> render_inline_list_page(label, posts, language, "date") end
|
render_archive_list_page(plan, label, archive_context, posts, language, "date", pagination)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Shared by every archive kind so category/tag pages get the same post
|
||||||
|
# payloads (loaded bodies, real hrefs) as date archives. The document title
|
||||||
|
# stays the blog title; the archive-specific label lives in `archive_context`.
|
||||||
|
defp render_archive_list_page(plan, title, archive_context, posts, language, kind, pagination) do
|
||||||
|
fallback = fn -> render_inline_list_page(title, posts, language, kind) end
|
||||||
|
|
||||||
render_list_output(
|
render_list_output(
|
||||||
plan,
|
plan,
|
||||||
language,
|
language,
|
||||||
label,
|
blog_page_title(plan),
|
||||||
build_list_posts(plan.base_url, posts, Paths.route_language(plan.language, language)),
|
build_list_posts(plan, posts, Paths.route_language(plan.language, language)),
|
||||||
archive_context,
|
archive_context,
|
||||||
pagination,
|
pagination,
|
||||||
fallback
|
fallback
|
||||||
@@ -88,9 +87,13 @@ defmodule BDS.Generation.Renderers do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc "Try the project's post template; on error, fall back to the inline `fallback` thunk."
|
@doc "Try the project's post template; on error, fall back to the inline `fallback` thunk."
|
||||||
@spec render_post_output(String.t(), String.t() | nil, map(), (-> String.t())) :: String.t()
|
@spec render_post_output(RenderContext.t() | String.t(), String.t() | nil, map(), (-> String.t())) ::
|
||||||
def render_post_output(project_id, template_slug, assigns, fallback) do
|
String.t()
|
||||||
render_with_fallback(fn -> Rendering.render_post_page(project_id, template_slug, assigns) end, fallback)
|
def render_post_output(render_target, template_slug, assigns, fallback) do
|
||||||
|
render_with_fallback(
|
||||||
|
fn -> Rendering.render_post_page(render_target, template_slug, assigns) end,
|
||||||
|
fallback
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Render a list/archive page through the project template, falling back to inline."
|
@doc "Render a list/archive page through the project template, falling back to inline."
|
||||||
@@ -105,7 +108,7 @@ defmodule BDS.Generation.Renderers do
|
|||||||
) ::
|
) ::
|
||||||
String.t()
|
String.t()
|
||||||
def render_list_output(
|
def render_list_output(
|
||||||
%{project_id: project_id, language: main_language},
|
%{project_id: project_id, language: main_language} = plan,
|
||||||
language,
|
language,
|
||||||
page_title,
|
page_title,
|
||||||
posts,
|
posts,
|
||||||
@@ -116,7 +119,7 @@ defmodule BDS.Generation.Renderers do
|
|||||||
when is_binary(project_id) do
|
when is_binary(project_id) do
|
||||||
render_with_fallback(
|
render_with_fallback(
|
||||||
fn ->
|
fn ->
|
||||||
Rendering.render_list_page(project_id, %{
|
Rendering.render_list_page(plan_render_target(plan), %{
|
||||||
language: language,
|
language: language,
|
||||||
language_prefix: Paths.language_prefix(language, main_language),
|
language_prefix: Paths.language_prefix(language, main_language),
|
||||||
page_title: page_title,
|
page_title: page_title,
|
||||||
@@ -129,13 +132,24 @@ defmodule BDS.Generation.Renderers do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc "Blog document title (description, then name) shared by list/home pages."
|
||||||
|
@spec blog_page_title(map()) :: String.t()
|
||||||
|
def blog_page_title(plan) do
|
||||||
|
RenderMetadata.blog_page_title(
|
||||||
|
Map.get(plan, :project_description),
|
||||||
|
Map.get(plan, :project_name)
|
||||||
|
)
|
||||||
|
end
|
||||||
@doc "Render the project's 404 page via its template, falling back to a static page."
|
@doc "Render the project's 404 page via its template, falling back to a static page."
|
||||||
@spec render_not_found_output(map(), String.t() | nil) :: String.t()
|
@spec render_not_found_output(map(), String.t() | nil) :: String.t()
|
||||||
def render_not_found_output(%{project_id: project_id, language: main_language}, language)
|
def render_not_found_output(
|
||||||
|
%{project_id: project_id, language: main_language} = plan,
|
||||||
|
language
|
||||||
|
)
|
||||||
when is_binary(project_id) do
|
when is_binary(project_id) do
|
||||||
render_with_fallback(
|
render_with_fallback(
|
||||||
fn ->
|
fn ->
|
||||||
Rendering.render_not_found_page(project_id, %{
|
Rendering.render_not_found_page(plan_render_target(plan), %{
|
||||||
language: language,
|
language: language,
|
||||||
language_prefix: Paths.language_prefix(language, main_language)
|
language_prefix: Paths.language_prefix(language, main_language)
|
||||||
})
|
})
|
||||||
@@ -144,6 +158,15 @@ defmodule BDS.Generation.Renderers do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc "The render context stashed in the plan, or the bare project id when absent."
|
||||||
|
@spec plan_render_target(map()) :: RenderContext.t() | String.t()
|
||||||
|
def plan_render_target(plan) do
|
||||||
|
case Map.get(plan, :render_context) do
|
||||||
|
%RenderContext{} = ctx -> ctx
|
||||||
|
_other -> plan.project_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc "Static fallback HTML for a 404 page."
|
@doc "Static fallback HTML for a 404 page."
|
||||||
@spec render_not_found_page(String.t() | nil) :: String.t()
|
@spec render_not_found_page(String.t() | nil) :: String.t()
|
||||||
def render_not_found_page(language) do
|
def render_not_found_page(language) do
|
||||||
@@ -156,41 +179,49 @@ defmodule BDS.Generation.Renderers do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc "Build the list-of-posts payload (with hrefs and bodies) for archive/list templates."
|
@doc "Build the list-of-posts payload (with hrefs and bodies) for archive/list templates."
|
||||||
@spec build_list_posts(String.t() | nil, [map()], String.t() | nil) :: [map()]
|
@spec build_list_posts(map(), [map()], String.t() | nil) :: [map()]
|
||||||
def build_list_posts(base_url, posts, language_prefix) do
|
def build_list_posts(plan, posts, language_prefix) do
|
||||||
|
render_target = plan_render_target(plan)
|
||||||
|
|
||||||
Enum.map(posts, fn post ->
|
Enum.map(posts, fn post ->
|
||||||
%{
|
%{
|
||||||
id: post.id,
|
id: post.id,
|
||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
href: Paths.url_for_output(base_url, Paths.post_output_path(post, language_prefix)),
|
href: Paths.url_for_output(plan.base_url, Paths.post_output_path(post, language_prefix)),
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt,
|
||||||
content: load_body(post.project_id, post.file_path, post.content)
|
content: load_body(render_target, post.file_path, post.content)
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Load the post body from disk (or pass-through inline content) for list rendering."
|
@doc "Load the post body from disk (or pass-through inline content) for list rendering."
|
||||||
@spec load_body(String.t() | nil, String.t() | nil, String.t() | nil) :: String.t()
|
@spec load_body(RenderContext.t() | String.t() | nil, String.t() | nil, String.t() | nil) ::
|
||||||
def load_body(_project_id, _file_path, inline_content) when is_binary(inline_content),
|
String.t()
|
||||||
|
def load_body(_render_target, _file_path, inline_content) when is_binary(inline_content),
|
||||||
do: inline_content
|
do: inline_content
|
||||||
|
|
||||||
def load_body(project_id, file_path, _inline_content) do
|
def load_body(%RenderContext{} = ctx, file_path, _inline_content)
|
||||||
case file_path do
|
when is_binary(file_path) and file_path != "" do
|
||||||
nil ->
|
RenderContext.memoize(ctx, {:post_body, file_path}, fn ->
|
||||||
""
|
read_body_file(Path.expand(file_path, ctx.project_data_dir))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
"" ->
|
def load_body(project_id, file_path, _inline_content)
|
||||||
""
|
when is_binary(project_id) and is_binary(file_path) and file_path != "" do
|
||||||
|
project_id
|
||||||
|
|> Projects.get_project!()
|
||||||
|
|> Projects.project_data_dir()
|
||||||
|
|> then(&read_body_file(Path.expand(file_path, &1)))
|
||||||
|
end
|
||||||
|
|
||||||
value ->
|
def load_body(_render_target, _file_path, _inline_content), do: ""
|
||||||
project_path =
|
|
||||||
Path.expand(value, Projects.project_data_dir(Projects.get_project!(project_id)))
|
|
||||||
|
|
||||||
case File.read(project_path) do
|
defp read_body_file(full_path) do
|
||||||
{:ok, contents} -> parse_frontmatter_body(contents)
|
case File.read(full_path) do
|
||||||
{:error, _reason} -> ""
|
{:ok, contents} -> parse_frontmatter_body(contents)
|
||||||
end
|
{:error, _reason} -> ""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -212,13 +212,19 @@ defmodule BDS.Generation.Sitemap do
|
|||||||
"<feed><title>#{xml_escape(plan.project_name)} (#{xml_escape(language || "default")})</title>#{entries}</feed>"
|
"<feed><title>#{xml_escape(plan.project_name)} (#{xml_escape(language || "default")})</title>#{entries}</feed>"
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Render a JSON calendar of all published posts."
|
@doc """
|
||||||
|
Render the JSON calendar archive consumed by the calendar runtime:
|
||||||
|
post counts keyed by year ("2024"), month ("2024-05"), and day ("2024-05-17").
|
||||||
|
"""
|
||||||
@spec render_calendar([map()]) :: String.t()
|
@spec render_calendar([map()]) :: String.t()
|
||||||
def render_calendar(published_posts) do
|
def render_calendar(published_posts) do
|
||||||
published_posts
|
day_keys = Enum.map(published_posts, &Paths.local_date_iso8601!(&1.created_at))
|
||||||
|> Enum.map(fn post ->
|
|
||||||
%{date: Paths.local_date_iso8601!(post.created_at), slug: post.slug, title: post.title}
|
%{
|
||||||
end)
|
years: Enum.frequencies_by(day_keys, &binary_part(&1, 0, 4)),
|
||||||
|
months: Enum.frequencies_by(day_keys, &binary_part(&1, 0, 7)),
|
||||||
|
days: Enum.frequencies(day_keys)
|
||||||
|
}
|
||||||
|> Jason.encode!()
|
|> Jason.encode!()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -245,7 +245,9 @@ defmodule BDS.Generation.Validation do
|
|||||||
requested_tag_slugs: MapSet.new(),
|
requested_tag_slugs: MapSet.new(),
|
||||||
requested_years: MapSet.new(),
|
requested_years: MapSet.new(),
|
||||||
requested_year_months: MapSet.new(),
|
requested_year_months: MapSet.new(),
|
||||||
|
requested_year_month_days: MapSet.new(),
|
||||||
requested_post_routes: [],
|
requested_post_routes: [],
|
||||||
|
requested_page_slugs: MapSet.new(),
|
||||||
language_plans: %{}
|
language_plans: %{}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -277,28 +279,38 @@ defmodule BDS.Generation.Validation do
|
|||||||
)
|
)
|
||||||
|
|
||||||
nil ->
|
nil ->
|
||||||
case Regex.run(~r|^/(\d{4})/(\d{2})(?:/page/\d+)?$|, path) do
|
classify_archive_or_page_validation_path(path, plan)
|
||||||
[_, year, month] ->
|
|
||||||
update_in(plan.requested_year_months, &MapSet.put(&1, "#{year}/#{month}"))
|
|
||||||
|
|
||||||
nil ->
|
|
||||||
case Regex.run(~r|^/(\d{4})(?:/page/\d+)?$|, path) do
|
|
||||||
[_, year] ->
|
|
||||||
update_in(plan.requested_years, &MapSet.put(&1, String.to_integer(year)))
|
|
||||||
|
|
||||||
nil ->
|
|
||||||
if path == "/" or Regex.match?(~r|^/page/\d+$|, path) do
|
|
||||||
%{plan | request_root_routes: true}
|
|
||||||
else
|
|
||||||
%{plan | requires_fallback_section_render: true}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp classify_archive_or_page_validation_path(path, plan) do
|
||||||
|
cond do
|
||||||
|
Regex.match?(~r|^/(\d{4})/(\d{2})/(\d{2})(?:/page/\d+)?$|, path) ->
|
||||||
|
[_, year, month, day] = Regex.run(~r|^/(\d{4})/(\d{2})/(\d{2})(?:/page/\d+)?$|, path)
|
||||||
|
update_in(plan.requested_year_month_days, &MapSet.put(&1, "#{year}/#{month}/#{day}"))
|
||||||
|
|
||||||
|
Regex.match?(~r|^/(\d{4})/(\d{2})(?:/page/\d+)?$|, path) ->
|
||||||
|
[_, year, month] = Regex.run(~r|^/(\d{4})/(\d{2})(?:/page/\d+)?$|, path)
|
||||||
|
update_in(plan.requested_year_months, &MapSet.put(&1, "#{year}/#{month}"))
|
||||||
|
|
||||||
|
Regex.match?(~r|^/(\d{4})(?:/page/\d+)?$|, path) ->
|
||||||
|
[_, year] = Regex.run(~r|^/(\d{4})(?:/page/\d+)?$|, path)
|
||||||
|
update_in(plan.requested_years, &MapSet.put(&1, String.to_integer(year)))
|
||||||
|
|
||||||
|
path == "/" or Regex.match?(~r|^/page/\d+$|, path) ->
|
||||||
|
%{plan | request_root_routes: true}
|
||||||
|
|
||||||
|
Regex.match?(~r|^/([^/]+)$|, path) ->
|
||||||
|
[_, slug] = Regex.run(~r|^/([^/]+)$|, path)
|
||||||
|
update_in(plan.requested_page_slugs, &MapSet.put(&1, slug))
|
||||||
|
|
||||||
|
true ->
|
||||||
|
%{plan | requires_fallback_section_render: true}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@spec build_targeted_validation_plan(map(), [map()]) :: map()
|
@spec build_targeted_validation_plan(map(), [map()]) :: map()
|
||||||
def build_targeted_validation_plan(initial_plan, published_posts) do
|
def build_targeted_validation_plan(initial_plan, published_posts) do
|
||||||
if initial_plan.requires_fallback_section_render do
|
if initial_plan.requires_fallback_section_render do
|
||||||
@@ -334,10 +346,14 @@ defmodule BDS.Generation.Validation do
|
|||||||
[:requested_year_months],
|
[:requested_year_months],
|
||||||
&MapSet.put(&1, route_month_key(route.year, route.month))
|
&MapSet.put(&1, route_month_key(route.year, route.month))
|
||||||
)
|
)
|
||||||
|
|> update_in(
|
||||||
|
[:requested_year_month_days],
|
||||||
|
&MapSet.put(&1, route_day_key(route.year, route.month, route.day))
|
||||||
|
)
|
||||||
|> Map.put(:request_root_routes, true)
|
|> Map.put(:request_root_routes, true)
|
||||||
|
|
||||||
post ->
|
post ->
|
||||||
{year, month, _day} = local_date_parts!(post.created_at)
|
{year, month, day} = local_date_parts!(post.created_at)
|
||||||
|
|
||||||
acc
|
acc
|
||||||
|> update_in([:requested_category_slugs], fn set ->
|
|> update_in([:requested_category_slugs], fn set ->
|
||||||
@@ -355,10 +371,15 @@ defmodule BDS.Generation.Validation do
|
|||||||
[:requested_year_months],
|
[:requested_year_months],
|
||||||
&MapSet.put(&1, route_month_key(year, month))
|
&MapSet.put(&1, route_month_key(year, month))
|
||||||
)
|
)
|
||||||
|
|> update_in(
|
||||||
|
[:requested_year_month_days],
|
||||||
|
&MapSet.put(&1, route_day_key(year, month, day))
|
||||||
|
)
|
||||||
|> Map.put(:request_root_routes, true)
|
|> Map.put(:request_root_routes, true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
)
|
)
|
||||||
|
|> cascade_requested_date_archives()
|
||||||
|
|
||||||
language_plans =
|
language_plans =
|
||||||
initial_plan.language_plans
|
initial_plan.language_plans
|
||||||
@@ -384,7 +405,8 @@ defmodule BDS.Generation.Validation do
|
|||||||
post.slug == route.slug and year == route.year and month == route.month and day == route.day
|
post.slug == route.slug and year == route.year and month == route.month and day == route.day
|
||||||
end
|
end
|
||||||
|
|
||||||
defp route_key(year, month, day, slug) do
|
@spec route_key(integer(), integer(), integer(), String.t()) :: String.t()
|
||||||
|
def route_key(year, month, day, slug) do
|
||||||
"#{year}/#{String.pad_leading(Integer.to_string(month), 2, "0")}/#{String.pad_leading(Integer.to_string(day), 2, "0")}/#{slug}"
|
"#{year}/#{String.pad_leading(Integer.to_string(month), 2, "0")}/#{String.pad_leading(Integer.to_string(day), 2, "0")}/#{slug}"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -392,6 +414,33 @@ defmodule BDS.Generation.Validation do
|
|||||||
"#{year}/#{String.pad_leading(Integer.to_string(month), 2, "0")}"
|
"#{year}/#{String.pad_leading(Integer.to_string(month), 2, "0")}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp route_day_key(year, month, day) do
|
||||||
|
"#{route_month_key(year, month)}/#{String.pad_leading(Integer.to_string(day), 2, "0")}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cascade_requested_date_archives(plan) do
|
||||||
|
plan
|
||||||
|
|> cascade_requested_year_month_days()
|
||||||
|
|> cascade_requested_year_months()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cascade_requested_year_month_days(plan) do
|
||||||
|
Enum.reduce(plan.requested_year_month_days, plan, fn year_month_day, acc ->
|
||||||
|
[year, month, _day] = String.split(year_month_day, "/", parts: 3)
|
||||||
|
|
||||||
|
acc
|
||||||
|
|> update_in([:requested_year_months], &MapSet.put(&1, "#{year}/#{month}"))
|
||||||
|
|> update_in([:requested_years], &MapSet.put(&1, String.to_integer(year)))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cascade_requested_year_months(plan) do
|
||||||
|
Enum.reduce(plan.requested_year_months, plan, fn year_month, acc ->
|
||||||
|
[year | _rest] = String.split(year_month, "/", parts: 2)
|
||||||
|
update_in(acc.requested_years, &MapSet.put(&1, String.to_integer(year)))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp extract_language_path(path, additional_languages) do
|
defp extract_language_path(path, additional_languages) do
|
||||||
case Regex.run(~r|^/([a-z]{2,3})(/.*)?$|, path) do
|
case Regex.run(~r|^/([a-z]{2,3})(/.*)?$|, path) do
|
||||||
[_, language, suffix] ->
|
[_, language, suffix] ->
|
||||||
@@ -413,83 +462,6 @@ defmodule BDS.Generation.Validation do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec targeted_output?(String.t(), map(), String.t() | nil, [String.t()]) :: boolean()
|
|
||||||
def targeted_output?(relative_path, targeted_plan, main_language, additional_languages) do
|
|
||||||
{language, stripped_path} =
|
|
||||||
extract_relative_output_language(relative_path, additional_languages)
|
|
||||||
|
|
||||||
plan =
|
|
||||||
case language do
|
|
||||||
nil -> targeted_plan
|
|
||||||
value -> Map.get(targeted_plan.language_plans, value, empty_validation_path_plan())
|
|
||||||
end
|
|
||||||
|
|
||||||
targeted_output_for_plan?(stripped_path, plan, main_language == language or is_nil(language))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_relative_output_language(relative_path, additional_languages) do
|
|
||||||
segments = String.split(relative_path, "/", trim: true)
|
|
||||||
|
|
||||||
case segments do
|
|
||||||
[language | rest] ->
|
|
||||||
if language in additional_languages do
|
|
||||||
{language, Path.join(rest)}
|
|
||||||
else
|
|
||||||
{nil, relative_path}
|
|
||||||
end
|
|
||||||
|
|
||||||
_other ->
|
|
||||||
{nil, relative_path}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp targeted_output_for_plan?(
|
|
||||||
_relative_path,
|
|
||||||
%{requires_fallback_section_render: true},
|
|
||||||
_main?
|
|
||||||
),
|
|
||||||
do: true
|
|
||||||
|
|
||||||
defp targeted_output_for_plan?(relative_path, plan, _main?) do
|
|
||||||
cond do
|
|
||||||
relative_path in ["index.html", "404.html", "feed.xml", "atom.xml"] ->
|
|
||||||
plan.request_root_routes
|
|
||||||
|
|
||||||
Regex.match?(~r|^category/([^/]+)(?:/page/\d+)?/index\.html$|, relative_path) ->
|
|
||||||
[_, slug] = Regex.run(~r|^category/([^/]+)(?:/page/\d+)?/index\.html$|, relative_path)
|
|
||||||
MapSet.member?(plan.requested_category_slugs, slug)
|
|
||||||
|
|
||||||
Regex.match?(~r|^tag/([^/]+)/index\.html$|, relative_path) ->
|
|
||||||
[_, slug] = Regex.run(~r|^tag/([^/]+)/index\.html$|, relative_path)
|
|
||||||
MapSet.member?(plan.requested_tag_slugs, slug)
|
|
||||||
|
|
||||||
Regex.match?(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path) ->
|
|
||||||
[_, year, month, day, slug] =
|
|
||||||
Regex.run(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path)
|
|
||||||
|
|
||||||
MapSet.member?(
|
|
||||||
plan.requested_post_routes,
|
|
||||||
route_key(
|
|
||||||
String.to_integer(year),
|
|
||||||
String.to_integer(month),
|
|
||||||
String.to_integer(day),
|
|
||||||
slug
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Regex.match?(~r|^(\d{4})/(\d{2})/index\.html$|, relative_path) ->
|
|
||||||
[_, year, month] = Regex.run(~r|^(\d{4})/(\d{2})/index\.html$|, relative_path)
|
|
||||||
MapSet.member?(plan.requested_year_months, "#{year}/#{month}")
|
|
||||||
|
|
||||||
Regex.match?(~r|^(\d{4})/index\.html$|, relative_path) ->
|
|
||||||
[_, year] = Regex.run(~r|^(\d{4})/index\.html$|, relative_path)
|
|
||||||
MapSet.member?(plan.requested_years, String.to_integer(year))
|
|
||||||
|
|
||||||
true ->
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec route_html_path?(String.t()) :: boolean()
|
@spec route_html_path?(String.t()) :: boolean()
|
||||||
def route_html_path?(relative_path), do: String.ends_with?(relative_path, "index.html")
|
def route_html_path?(relative_path), do: String.ends_with?(relative_path, "index.html")
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ defmodule BDS.Git do
|
|||||||
opts
|
opts
|
||||||
),
|
),
|
||||||
{:ok, remote_log} <- remote_history_log(project_dir, branch, opts) do
|
{:ok, remote_log} <- remote_history_log(project_dir, branch, opts) do
|
||||||
|
|
||||||
local_commits = parse_history_log(local_log)
|
local_commits = parse_history_log(local_log)
|
||||||
remote_hashes = MapSet.new(parse_remote_history(remote_log))
|
remote_hashes = MapSet.new(parse_remote_history(remote_log))
|
||||||
local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash))
|
local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash))
|
||||||
@@ -194,6 +193,30 @@ defmodule BDS.Git do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Start a streaming network git command (`:fetch | :pull | :push`), forwarding
|
||||||
|
output to `subscriber` (see `BDS.Git.Stream`) so the UI can show it live and
|
||||||
|
cancel a hanging run. Returns `{:ok, pid, ref}`, or `{:error, reason}` when the
|
||||||
|
project can't be resolved.
|
||||||
|
"""
|
||||||
|
def stream(project_id, operation, subscriber, opts \\ [])
|
||||||
|
when is_binary(project_id) and operation in [:fetch, :pull, :push] and is_pid(subscriber) do
|
||||||
|
with {:ok, project_dir} <- project_dir(project_id) do
|
||||||
|
env = command_opts(project_dir, 0)[:env]
|
||||||
|
|
||||||
|
BDS.Git.Stream.start(
|
||||||
|
subscriber,
|
||||||
|
project_dir,
|
||||||
|
stream_args(operation),
|
||||||
|
Keyword.put(opts, :env, env)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp stream_args(:fetch), do: ["fetch", "--all", "--prune", "--progress"]
|
||||||
|
defp stream_args(:pull), do: ["pull", "--ff-only", "--progress"]
|
||||||
|
defp stream_args(:push), do: ["push", "--progress"]
|
||||||
|
|
||||||
def commit_all(project_id, message, opts \\ [])
|
def commit_all(project_id, message, opts \\ [])
|
||||||
when is_binary(project_id) and is_binary(message) and is_list(opts) do
|
when is_binary(project_id) and is_binary(message) and is_list(opts) do
|
||||||
with {:ok, project_dir} <- project_dir(project_id),
|
with {:ok, project_dir} <- project_dir(project_id),
|
||||||
@@ -406,11 +429,21 @@ defmodule BDS.Git do
|
|||||||
env: %{
|
env: %{
|
||||||
"GIT_TERMINAL_PROMPT" => "0",
|
"GIT_TERMINAL_PROMPT" => "0",
|
||||||
"GCM_INTERACTIVE" => "never",
|
"GCM_INTERACTIVE" => "never",
|
||||||
"GIT_SSH_COMMAND" => ssh_command
|
"GIT_SSH_COMMAND" => ssh_command,
|
||||||
|
"PATH" => git_path()
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A macOS app launched from Finder inherits a minimal PATH and never sources
|
||||||
|
# the login shell, so Homebrew tools like git-lfs (called from git hooks) are
|
||||||
|
# not found. Prepend the standard Homebrew bin dirs to the inherited PATH.
|
||||||
|
defp git_path do
|
||||||
|
["/opt/homebrew/bin", "/usr/local/bin", System.get_env("PATH") || ""]
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|> Enum.join(":")
|
||||||
|
end
|
||||||
|
|
||||||
defp upstream_branch(project_dir, opts) do
|
defp upstream_branch(project_dir, opts) do
|
||||||
case run_git(
|
case run_git(
|
||||||
project_dir,
|
project_dir,
|
||||||
|
|||||||
142
lib/bds/git/stream.ex
Normal file
142
lib/bds/git/stream.ex
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
defmodule BDS.Git.Stream do
|
||||||
|
@moduledoc """
|
||||||
|
Runs a git command and streams its output to a subscriber process as it
|
||||||
|
arrives, keeping stdout and stderr on separate channels.
|
||||||
|
|
||||||
|
A plain Erlang port merges stderr into stdout, so we redirect the command's
|
||||||
|
stderr through a FIFO (`sh -c 'exec "$0" "$@" 2>fifo'`) and read that FIFO with
|
||||||
|
a second `cat` port. `sh`, `cat` and `mkfifo` are system binaries, so no extra
|
||||||
|
dependency is bundled.
|
||||||
|
|
||||||
|
The subscriber receives:
|
||||||
|
|
||||||
|
* `{:git_output, ref, :stdout, chunk}`
|
||||||
|
* `{:git_output, ref, :stderr, chunk}`
|
||||||
|
* `{:git_done, ref, exit_status}` (`0` on success)
|
||||||
|
|
||||||
|
Send `cancel/1` to terminate a hanging command; the OS process is killed and a
|
||||||
|
`{:git_done, ref, status}` still follows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@drain_ms 200
|
||||||
|
|
||||||
|
@spec start(pid, String.t(), [String.t()], keyword) :: {:ok, pid, reference}
|
||||||
|
def start(subscriber, cwd, args, opts \\ [])
|
||||||
|
when is_pid(subscriber) and is_binary(cwd) and is_list(args) do
|
||||||
|
ref = make_ref()
|
||||||
|
pid = spawn(fn -> init(subscriber, ref, cwd, args, opts) end)
|
||||||
|
{:ok, pid, ref}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec cancel(pid) :: :ok
|
||||||
|
def cancel(pid) when is_pid(pid) do
|
||||||
|
send(pid, :cancel)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp init(subscriber, ref, cwd, args, opts) do
|
||||||
|
command = Keyword.get(opts, :command, "git")
|
||||||
|
env = Keyword.get(opts, :env, %{})
|
||||||
|
|
||||||
|
case System.find_executable(command) do
|
||||||
|
nil ->
|
||||||
|
send(subscriber, {:git_output, ref, :stderr, "missing executable: #{command}\n"})
|
||||||
|
send(subscriber, {:git_done, ref, 127})
|
||||||
|
|
||||||
|
executable ->
|
||||||
|
run(subscriber, ref, cwd, executable, args, env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run(subscriber, ref, cwd, executable, args, env) do
|
||||||
|
fifo_dir = Path.join(System.tmp_dir!(), "bds-git-fifo-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(fifo_dir)
|
||||||
|
fifo = Path.join(fifo_dir, "stderr")
|
||||||
|
|
||||||
|
try do
|
||||||
|
{_out, 0} = System.cmd("mkfifo", [fifo])
|
||||||
|
|
||||||
|
# exec replaces sh with the target binary, so the OS pid and exit status we
|
||||||
|
# observe on this port are the command's own.
|
||||||
|
script = ~s(exec "$0" "$@" 2>'#{fifo}')
|
||||||
|
|
||||||
|
out_port =
|
||||||
|
Port.open(
|
||||||
|
{:spawn_executable, System.find_executable("sh")},
|
||||||
|
[
|
||||||
|
:binary,
|
||||||
|
:exit_status,
|
||||||
|
:use_stdio,
|
||||||
|
:hide,
|
||||||
|
{:cd, cwd},
|
||||||
|
{:env, port_env(env)},
|
||||||
|
{:args, ["-c", script, executable | args]}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
err_port =
|
||||||
|
Port.open(
|
||||||
|
{:spawn_executable, System.find_executable("cat")},
|
||||||
|
[:binary, :exit_status, :use_stdio, :hide, {:args, [fifo]}]
|
||||||
|
)
|
||||||
|
|
||||||
|
loop(subscriber, ref, out_port, err_port)
|
||||||
|
after
|
||||||
|
File.rm_rf(fifo_dir)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp loop(subscriber, ref, out_port, err_port) do
|
||||||
|
receive do
|
||||||
|
{^out_port, {:data, data}} ->
|
||||||
|
send(subscriber, {:git_output, ref, :stdout, data})
|
||||||
|
loop(subscriber, ref, out_port, err_port)
|
||||||
|
|
||||||
|
{^err_port, {:data, data}} ->
|
||||||
|
send(subscriber, {:git_output, ref, :stderr, data})
|
||||||
|
loop(subscriber, ref, out_port, err_port)
|
||||||
|
|
||||||
|
{^err_port, {:exit_status, _status}} ->
|
||||||
|
loop(subscriber, ref, out_port, nil)
|
||||||
|
|
||||||
|
{^out_port, {:exit_status, status}} ->
|
||||||
|
drain(subscriber, ref, err_port)
|
||||||
|
send(subscriber, {:git_done, ref, status})
|
||||||
|
|
||||||
|
:cancel ->
|
||||||
|
kill(out_port)
|
||||||
|
loop(subscriber, ref, out_port, err_port)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp drain(_subscriber, _ref, nil), do: :ok
|
||||||
|
|
||||||
|
defp drain(subscriber, ref, err_port) do
|
||||||
|
receive do
|
||||||
|
{^err_port, {:data, data}} ->
|
||||||
|
send(subscriber, {:git_output, ref, :stderr, data})
|
||||||
|
drain(subscriber, ref, err_port)
|
||||||
|
|
||||||
|
{^err_port, {:exit_status, _status}} ->
|
||||||
|
:ok
|
||||||
|
after
|
||||||
|
@drain_ms -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp kill(port) do
|
||||||
|
case Port.info(port, :os_pid) do
|
||||||
|
{:os_pid, os_pid} when is_integer(os_pid) ->
|
||||||
|
System.cmd("kill", ["-TERM", Integer.to_string(os_pid)], stderr_to_stdout: true)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp port_env(env) do
|
||||||
|
Enum.map(env, fn {key, value} ->
|
||||||
|
{String.to_charlist(to_string(key)), String.to_charlist(to_string(value))}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -62,10 +62,23 @@ defmodule BDS.I18n do
|
|||||||
def resolve_ui_locale(locale), do: resolve_supported_locale(locale) || @default_language
|
def resolve_ui_locale(locale), do: resolve_supported_locale(locale) || @default_language
|
||||||
|
|
||||||
def current_ui_locale do
|
def current_ui_locale do
|
||||||
(System.get_env("LC_ALL") || System.get_env("LC_MESSAGES") || System.get_env("LANG"))
|
(stored_ui_locale() || System.get_env("LC_ALL") || System.get_env("LC_MESSAGES") ||
|
||||||
|
System.get_env("LANG"))
|
||||||
|> resolve_ui_locale()
|
|> resolve_ui_locale()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The UI language is a server-side setting shared by all connected clients
|
||||||
|
# (issue #26); the OS locale is only the fallback for a fresh install.
|
||||||
|
defp stored_ui_locale do
|
||||||
|
with true <- BDS.Repo.ready?(),
|
||||||
|
value when is_binary(value) and value != "" <-
|
||||||
|
BDS.Settings.get_global_setting("ui.language") do
|
||||||
|
value
|
||||||
|
else
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def resolve_render_locale(language), do: resolve_supported_locale(language) || @default_language
|
def resolve_render_locale(language), do: resolve_supported_locale(language) || @default_language
|
||||||
|
|
||||||
def format_locale(language) do
|
def format_locale(language) do
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ defmodule BDS.Maintenance.DiffReports do
|
|||||||
]
|
]
|
||||||
|
|
||||||
alias BDS.DocumentFields
|
alias BDS.DocumentFields
|
||||||
|
alias BDS.Media.Linking
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
alias BDS.Media.Translation, as: MediaTranslation
|
alias BDS.Media.Translation, as: MediaTranslation
|
||||||
alias BDS.Metadata
|
alias BDS.Metadata
|
||||||
@@ -163,7 +164,12 @@ defmodule BDS.Maintenance.DiffReports do
|
|||||||
diff_field("language", media.language, Map.get(fields, "language")),
|
diff_field("language", media.language, Map.get(fields, "language")),
|
||||||
diff_field("created_at", media.created_at, DocumentFields.get(fields, "createdAt")),
|
diff_field("created_at", media.created_at, DocumentFields.get(fields, "createdAt")),
|
||||||
diff_field("updated_at", media.updated_at, DocumentFields.get(fields, "updatedAt")),
|
diff_field("updated_at", media.updated_at, DocumentFields.get(fields, "updatedAt")),
|
||||||
diff_field("tags", media.tags, Map.get(fields, "tags", []))
|
diff_field("tags", media.tags, Map.get(fields, "tags", [])),
|
||||||
|
diff_field(
|
||||||
|
"linked_post_ids",
|
||||||
|
Linking.linked_post_ids(media.id),
|
||||||
|
DocumentFields.get(fields, "linkedPostIds", [])
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ defmodule BDS.Media do
|
|||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.Events
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
alias BDS.Media.Translation
|
alias BDS.Media.Translation
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
@@ -66,6 +67,12 @@ defmodule BDS.Media do
|
|||||||
defdelegate link_media_to_post(media_id, post_id), to: BDS.Media.Linking
|
defdelegate link_media_to_post(media_id, post_id), to: BDS.Media.Linking
|
||||||
defdelegate unlink_media_from_post(media_id, post_id), to: BDS.Media.Linking
|
defdelegate unlink_media_from_post(media_id, post_id), to: BDS.Media.Linking
|
||||||
|
|
||||||
|
defdelegate rebuild_media_links(project_id), to: BDS.Media.Linking,
|
||||||
|
as: :rebuild_links_from_sidecars
|
||||||
|
|
||||||
|
defdelegate rebuild_media_links(project_id, opts), to: BDS.Media.Linking,
|
||||||
|
as: :rebuild_links_from_sidecars
|
||||||
|
|
||||||
defdelegate rebuild_media_from_files(project_id), to: BDS.Media.Rebuilder
|
defdelegate rebuild_media_from_files(project_id), to: BDS.Media.Rebuilder
|
||||||
defdelegate rebuild_media_from_files(project_id, opts), to: BDS.Media.Rebuilder
|
defdelegate rebuild_media_from_files(project_id, opts), to: BDS.Media.Rebuilder
|
||||||
|
|
||||||
@@ -115,6 +122,7 @@ defmodule BDS.Media do
|
|||||||
log_sidecar_error(write_sidecar(project, media), media.id)
|
log_sidecar_error(write_sidecar(project, media), media.id)
|
||||||
log_thumbnail_error(ensure_thumbnails(project, media), media.id)
|
log_thumbnail_error(ensure_thumbnails(project, media), media.id)
|
||||||
:ok = Search.sync_media(media)
|
:ok = Search.sync_media(media)
|
||||||
|
:ok = Events.entity_changed("media", media.id, :created)
|
||||||
{:ok, media}
|
{:ok, media}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
@@ -156,6 +164,7 @@ defmodule BDS.Media do
|
|||||||
{:ok, updated_media} ->
|
{:ok, updated_media} ->
|
||||||
log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
|
log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
|
||||||
:ok = Search.sync_media(updated_media)
|
:ok = Search.sync_media(updated_media)
|
||||||
|
:ok = Events.entity_changed("media", updated_media.id, :updated)
|
||||||
{:ok, updated_media}
|
{:ok, updated_media}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
@@ -205,6 +214,7 @@ defmodule BDS.Media do
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
Search.delete_media(media.id)
|
Search.delete_media(media.id)
|
||||||
|
:ok = Events.entity_changed("media", media.id, :deleted)
|
||||||
{:ok, :deleted}
|
{:ok, :deleted}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ defmodule BDS.Media.Linking do
|
|||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
import BDS.Media.FileOps,
|
||||||
|
only: [
|
||||||
|
progress_callback: 1,
|
||||||
|
report_rebuild_progress: 4,
|
||||||
|
report_rebuild_started: 3
|
||||||
|
]
|
||||||
|
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
alias BDS.Media.Sidecars
|
alias BDS.Media.Sidecars
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
@@ -114,6 +121,92 @@ defmodule BDS.Media.Linking do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Restore `PostMedia` links for `media_id` from the list of `post_ids` persisted
|
||||||
|
in the media sidecar (`linkedPostIds`).
|
||||||
|
|
||||||
|
Used by rebuild-from-filesystem so gallery associations survive a rebuild (and
|
||||||
|
so projects whose link data lives only in sidecars still populate galleries).
|
||||||
|
Idempotent: skips links that already exist and post ids that are not present
|
||||||
|
in the database. Does not re-write sidecars.
|
||||||
|
"""
|
||||||
|
@spec restore_post_links(String.t(), String.t(), [String.t()] | term()) :: :ok
|
||||||
|
def restore_post_links(project_id, media_id, post_ids)
|
||||||
|
when is_binary(project_id) and is_binary(media_id) and is_list(post_ids) do
|
||||||
|
post_ids
|
||||||
|
|> Enum.filter(&is_binary/1)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> Enum.each(fn post_id -> ensure_link(project_id, media_id, post_id) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def restore_post_links(_project_id, _media_id, _post_ids), do: :ok
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Rebuild the `post_media` junction for a project from every media sidecar's
|
||||||
|
`linkedPostIds`.
|
||||||
|
|
||||||
|
Media sidecars are the source of truth for gallery associations (matching the
|
||||||
|
old bDS `rebuildFromSidecars`). Existing project links are cleared and then
|
||||||
|
re-created from the on-disk sidecars, so galleries reflect the project's
|
||||||
|
metadata even if the junction table drifted or was never populated. Links to
|
||||||
|
posts that are not present in the database are skipped.
|
||||||
|
"""
|
||||||
|
@spec rebuild_links_from_sidecars(String.t(), keyword()) ::
|
||||||
|
{:ok, %{media: non_neg_integer(), links: non_neg_integer()}}
|
||||||
|
def rebuild_links_from_sidecars(project_id, opts \\ []) when is_binary(project_id) do
|
||||||
|
project = Projects.get_project!(project_id)
|
||||||
|
on_progress = progress_callback(opts)
|
||||||
|
|
||||||
|
media_list =
|
||||||
|
Repo.all(from m in Media, where: m.project_id == ^project_id, order_by: [asc: m.id])
|
||||||
|
|
||||||
|
total = length(media_list)
|
||||||
|
:ok = report_rebuild_started(on_progress, total, "media links")
|
||||||
|
|
||||||
|
{_cleared, _} =
|
||||||
|
Repo.delete_all(from pm in PostMedia, where: pm.project_id == ^project_id)
|
||||||
|
|
||||||
|
media_list
|
||||||
|
|> Enum.with_index(1)
|
||||||
|
|> Enum.each(fn {media, index} ->
|
||||||
|
post_ids = Sidecars.read_linked_post_ids(project, media)
|
||||||
|
restore_post_links(project_id, media.id, post_ids)
|
||||||
|
:ok = report_rebuild_progress(on_progress, index, total, "media links")
|
||||||
|
end)
|
||||||
|
|
||||||
|
links =
|
||||||
|
Repo.aggregate(from(pm in PostMedia, where: pm.project_id == ^project_id), :count)
|
||||||
|
|
||||||
|
{:ok, %{media: total, links: links}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_link(project_id, media_id, post_id) do
|
||||||
|
already_linked? =
|
||||||
|
Repo.exists?(
|
||||||
|
from pm in PostMedia,
|
||||||
|
where: pm.media_id == ^media_id and pm.post_id == ^post_id
|
||||||
|
)
|
||||||
|
|
||||||
|
post_exists? = Repo.exists?(from p in BDS.Posts.Post, where: p.id == ^post_id)
|
||||||
|
|
||||||
|
if not already_linked? and post_exists? do
|
||||||
|
%PostMedia{}
|
||||||
|
|> PostMedia.changeset(%{
|
||||||
|
id: Ecto.UUID.generate(),
|
||||||
|
project_id: project_id,
|
||||||
|
post_id: post_id,
|
||||||
|
media_id: media_id,
|
||||||
|
sort_order: next_sort_order(media_id),
|
||||||
|
created_at: Persistence.now_ms()
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp log_sidecar_error(:ok, _media_id), do: :ok
|
defp log_sidecar_error(:ok, _media_id), do: :ok
|
||||||
|
|
||||||
defp log_sidecar_error({:error, reason}, media_id) do
|
defp log_sidecar_error({:error, reason}, media_id) do
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ defmodule BDS.Media.Sidecars do
|
|||||||
|> Media.changeset(attrs)
|
|> Media.changeset(attrs)
|
||||||
|> Repo.insert_or_update!()
|
|> Repo.insert_or_update!()
|
||||||
|
|
||||||
|
Linking.restore_post_links(
|
||||||
|
project.id,
|
||||||
|
media.id,
|
||||||
|
DocumentFields.get(sidecar.fields, "linkedPostIds", [])
|
||||||
|
)
|
||||||
|
|
||||||
if Keyword.get(opts, :sync_search, true) do
|
if Keyword.get(opts, :sync_search, true) do
|
||||||
:ok = Search.sync_media(media)
|
:ok = Search.sync_media(media)
|
||||||
end
|
end
|
||||||
@@ -343,6 +349,29 @@ defmodule BDS.Media.Sidecars do
|
|||||||
|> File.exists?()
|
|> File.exists?()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Read the `linkedPostIds` list persisted in a media item's canonical sidecar.
|
||||||
|
|
||||||
|
The media sidecar is the source of truth for gallery associations (matching
|
||||||
|
the old bDS `linkedPostIds` field). Returns `[]` when the sidecar is missing
|
||||||
|
or has no linked posts.
|
||||||
|
"""
|
||||||
|
@spec read_linked_post_ids(BDS.Projects.Project.t(), Media.t()) :: [String.t()]
|
||||||
|
def read_linked_post_ids(project, %Media{} = media) do
|
||||||
|
sidecar_path = Path.join(Projects.project_data_dir(project), media.sidecar_path)
|
||||||
|
|
||||||
|
case parse_existing_canonical_sidecar(project, sidecar_path) do
|
||||||
|
{:ok, sidecar} ->
|
||||||
|
sidecar.fields
|
||||||
|
|> DocumentFields.get("linkedPostIds", [])
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.filter(&is_binary/1)
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp parse_existing_canonical_sidecar(project, sidecar_path) do
|
defp parse_existing_canonical_sidecar(project, sidecar_path) do
|
||||||
if File.exists?(sidecar_path) do
|
if File.exists?(sidecar_path) do
|
||||||
parse_canonical_sidecar(project, sidecar_path)
|
parse_canonical_sidecar(project, sidecar_path)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ defmodule BDS.Posts do
|
|||||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||||
|
|
||||||
alias BDS.Embeddings
|
alias BDS.Embeddings
|
||||||
|
alias BDS.Events
|
||||||
alias BDS.Media
|
alias BDS.Media
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
alias BDS.PostLinks
|
alias BDS.PostLinks
|
||||||
@@ -106,7 +107,7 @@ defmodule BDS.Posts do
|
|||||||
{:ok, post} ->
|
{:ok, post} ->
|
||||||
:ok = Embeddings.sync_post(post)
|
:ok = Embeddings.sync_post(post)
|
||||||
:ok = Search.sync_post(post)
|
:ok = Search.sync_post(post)
|
||||||
:ok = AutoTranslation.maybe_schedule(post)
|
:ok = Events.entity_changed("post", post.id, :created)
|
||||||
{:ok, post}
|
{:ok, post}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
@@ -114,9 +115,9 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec update_post(String.t(), attrs()) ::
|
@spec update_post(String.t(), attrs(), keyword()) ::
|
||||||
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def update_post(post_id, attrs) do
|
def update_post(post_id, attrs, opts \\ []) do
|
||||||
case Repo.get(Post, post_id) do
|
case Repo.get(Post, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
@@ -145,7 +146,14 @@ defmodule BDS.Posts do
|
|||||||
:ok = Embeddings.sync_post(updated_post)
|
:ok = Embeddings.sync_post(updated_post)
|
||||||
:ok = PostLinks.sync_post_links(updated_post)
|
:ok = PostLinks.sync_post_links(updated_post)
|
||||||
:ok = Search.sync_post(updated_post)
|
:ok = Search.sync_post(updated_post)
|
||||||
:ok = AutoTranslation.maybe_schedule(updated_post)
|
|
||||||
|
# Auto-translation only runs on an explicit manual save from the
|
||||||
|
# editor, never on post creation or autosave.
|
||||||
|
if Keyword.get(opts, :auto_translate, false) do
|
||||||
|
:ok = AutoTranslation.maybe_schedule(updated_post)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok = Events.entity_changed("post", updated_post.id, :updated)
|
||||||
{:ok, updated_post}
|
{:ok, updated_post}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
@@ -197,6 +205,7 @@ defmodule BDS.Posts do
|
|||||||
:ok = Translations.publish_post_translations(updated_post)
|
:ok = Translations.publish_post_translations(updated_post)
|
||||||
:ok = PostLinks.sync_post_links(updated_post)
|
:ok = PostLinks.sync_post_links(updated_post)
|
||||||
:ok = Search.sync_post(updated_post)
|
:ok = Search.sync_post(updated_post)
|
||||||
|
:ok = Events.entity_changed("post", updated_post.id, :updated)
|
||||||
{:ok, updated_post}
|
{:ok, updated_post}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
@@ -330,6 +339,7 @@ defmodule BDS.Posts do
|
|||||||
PostLinks.delete_post_links(deleted_post.id)
|
PostLinks.delete_post_links(deleted_post.id)
|
||||||
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
|
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
|
||||||
Search.delete_post(deleted_post.id)
|
Search.delete_post(deleted_post.id)
|
||||||
|
:ok = Events.entity_changed("post", deleted_post.id, :deleted)
|
||||||
{:ok, :deleted}
|
{:ok, :deleted}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
|
|||||||
@@ -201,16 +201,21 @@ defmodule BDS.Preview do
|
|||||||
query_params: query_params
|
query_params: query_params
|
||||||
)
|
)
|
||||||
|
|
||||||
|
:static ->
|
||||||
|
serve_file(safe_join(Path.join(server.data_dir, "html"), relative_path),
|
||||||
|
server: server,
|
||||||
|
query_params: query_params
|
||||||
|
)
|
||||||
|
|
||||||
:generated ->
|
:generated ->
|
||||||
|
# Content is always rendered dynamically; generated files on
|
||||||
|
# disk go stale and must never be served by the preview.
|
||||||
case BDS.Preview.Router.render_route(server.project_id, request_path) do
|
case BDS.Preview.Router.render_route(server.project_id, request_path) do
|
||||||
{:ok, response} ->
|
{:ok, response} ->
|
||||||
{:ok, apply_response_overrides(response, query_params)}
|
{:ok, apply_response_overrides(response, query_params)}
|
||||||
|
|
||||||
:not_matched ->
|
:not_matched ->
|
||||||
serve_file(safe_join(Path.join(server.data_dir, "html"), relative_path),
|
render_not_found_response(server.project_id, query_params)
|
||||||
server: server,
|
|
||||||
query_params: query_params
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -319,6 +324,9 @@ defmodule BDS.Preview do
|
|||||||
match?(["media" | _], segments) ->
|
match?(["media" | _], segments) ->
|
||||||
{:ok, Path.join(tl(segments)), :media}
|
{:ok, Path.join(tl(segments)), :media}
|
||||||
|
|
||||||
|
match?(["pagefind" | _], segments) ->
|
||||||
|
{:ok, Path.join(segments), :static}
|
||||||
|
|
||||||
normalized == "/" ->
|
normalized == "/" ->
|
||||||
{:ok, "index.html", :generated}
|
{:ok, "index.html", :generated}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ defmodule BDS.Preview.Router do
|
|||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.Generation.Paths
|
alias BDS.Generation.Paths
|
||||||
|
alias BDS.Generation.Sitemap
|
||||||
alias BDS.MapUtils
|
alias BDS.MapUtils
|
||||||
alias BDS.Metadata, as: ProjectMetadata
|
alias BDS.Metadata, as: ProjectMetadata
|
||||||
alias BDS.Posts
|
alias BDS.Posts
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
alias BDS.Posts.Translation
|
alias BDS.Posts.Translation
|
||||||
alias BDS.Rendering
|
alias BDS.Rendering
|
||||||
|
alias BDS.Rendering.Metadata, as: RenderMetadata
|
||||||
alias BDS.Rendering.TemplateSelection
|
alias BDS.Rendering.TemplateSelection
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
|
|
||||||
@@ -22,6 +24,9 @@ defmodule BDS.Preview.Router do
|
|||||||
| {:year, integer(), pos_integer()}
|
| {:year, integer(), pos_integer()}
|
||||||
| {:month, integer(), integer(), pos_integer()}
|
| {:month, integer(), integer(), pos_integer()}
|
||||||
| {:day, integer(), integer(), integer(), pos_integer()}
|
| {:day, integer(), integer(), integer(), pos_integer()}
|
||||||
|
| :calendar
|
||||||
|
| :feed
|
||||||
|
| :atom
|
||||||
| :not_matched
|
| :not_matched
|
||||||
|
|
||||||
@spec render_route(String.t(), String.t()) :: {:ok, map()} | :not_matched
|
@spec render_route(String.t(), String.t()) :: {:ok, map()} | :not_matched
|
||||||
@@ -39,6 +44,28 @@ defmodule BDS.Preview.Router do
|
|||||||
:not_matched ->
|
:not_matched ->
|
||||||
:not_matched
|
:not_matched
|
||||||
|
|
||||||
|
:calendar ->
|
||||||
|
posts = load_published_list_posts(project_id, metadata)
|
||||||
|
{:ok, %{content_type: "application/json", body: Sitemap.render_calendar(posts)}}
|
||||||
|
|
||||||
|
:feed ->
|
||||||
|
posts = load_published_list_posts(project_id, metadata)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
content_type: "application/rss+xml",
|
||||||
|
body: Sitemap.render_feed(feed_plan(metadata), effective_language, posts)
|
||||||
|
}}
|
||||||
|
|
||||||
|
:atom ->
|
||||||
|
posts = load_published_list_posts(project_id, metadata)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
content_type: "application/atom+xml",
|
||||||
|
body: Sitemap.render_atom(feed_plan(metadata), effective_language, posts)
|
||||||
|
}}
|
||||||
|
|
||||||
route ->
|
route ->
|
||||||
case render(project_id, route, effective_language, main_language, metadata) do
|
case render(project_id, route, effective_language, main_language, metadata) do
|
||||||
{:ok, body} ->
|
{:ok, body} ->
|
||||||
@@ -52,6 +79,9 @@ defmodule BDS.Preview.Router do
|
|||||||
|
|
||||||
@spec match_route([String.t()]) :: route()
|
@spec match_route([String.t()]) :: route()
|
||||||
def match_route([]), do: {:home, 1}
|
def match_route([]), do: {:home, 1}
|
||||||
|
def match_route(["calendar.json"]), do: :calendar
|
||||||
|
def match_route(["rss.xml"]), do: :feed
|
||||||
|
def match_route(["atom.xml"]), do: :atom
|
||||||
def match_route(["page", n]), do: {:home, parse_page(n)}
|
def match_route(["page", n]), do: {:home, parse_page(n)}
|
||||||
|
|
||||||
def match_route(["category", name]), do: {:category, URI.decode(name), 1}
|
def match_route(["category", name]), do: {:category, URI.decode(name), 1}
|
||||||
@@ -129,6 +159,14 @@ defmodule BDS.Preview.Router do
|
|||||||
|
|
||||||
## Rendering
|
## Rendering
|
||||||
|
|
||||||
|
defp feed_plan(metadata) do
|
||||||
|
%{
|
||||||
|
base_url: metadata.public_url,
|
||||||
|
project_name: metadata.name,
|
||||||
|
language: metadata.main_language || "en"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
defp render(project_id, {:home, page_number}, language, main_language, metadata) do
|
defp render(project_id, {:home, page_number}, language, main_language, metadata) do
|
||||||
posts = load_published_list_posts(project_id, metadata)
|
posts = load_published_list_posts(project_id, metadata)
|
||||||
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
||||||
@@ -159,7 +197,8 @@ defmodule BDS.Preview.Router do
|
|||||||
|
|
||||||
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
||||||
kind: "category",
|
kind: "category",
|
||||||
name: name
|
name: RenderMetadata.category_display_name(metadata.category_settings, name),
|
||||||
|
slug: name
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -294,7 +333,7 @@ defmodule BDS.Preview.Router do
|
|||||||
assigns = %{
|
assigns = %{
|
||||||
language: language,
|
language: language,
|
||||||
language_prefix: language_prefix,
|
language_prefix: language_prefix,
|
||||||
page_title: archive_page_title(archive_ctx),
|
page_title: RenderMetadata.blog_page_title(metadata.description, metadata.name),
|
||||||
posts: page_posts,
|
posts: page_posts,
|
||||||
archive_context: archive_ctx,
|
archive_context: archive_ctx,
|
||||||
pagination: pagination
|
pagination: pagination
|
||||||
@@ -334,6 +373,10 @@ defmodule BDS.Preview.Router do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp archive_context_to_segments(%{kind: "core"}), do: []
|
defp archive_context_to_segments(%{kind: "core"}), do: []
|
||||||
|
|
||||||
|
defp archive_context_to_segments(%{kind: "category", slug: slug}) when is_binary(slug),
|
||||||
|
do: ["category", slug]
|
||||||
|
|
||||||
defp archive_context_to_segments(%{kind: "category", name: name}), do: ["category", name]
|
defp archive_context_to_segments(%{kind: "category", name: name}), do: ["category", name]
|
||||||
defp archive_context_to_segments(%{kind: "tag", name: name}), do: ["tag", name]
|
defp archive_context_to_segments(%{kind: "tag", name: name}), do: ["tag", name]
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ defmodule BDS.Publishing do
|
|||||||
|
|
||||||
{:ok, task} =
|
{:ok, task} =
|
||||||
Tasks.submit_task(
|
Tasks.submit_task(
|
||||||
"publish #{project_id}",
|
"publish #{project.name}",
|
||||||
fn report ->
|
fn report ->
|
||||||
run_upload(job_id, normalized_credentials, targets, uploader, report)
|
run_upload(job_id, normalized_credentials, targets, uploader, report)
|
||||||
end,
|
end,
|
||||||
@@ -181,7 +181,7 @@ defmodule BDS.Publishing do
|
|||||||
ssh_auth_sock
|
ssh_auth_sock
|
||||||
) do
|
) do
|
||||||
args =
|
args =
|
||||||
["--update", "--compress", "--verbose"] ++
|
["--update", "--compress", "--verbose", "--recursive", "--times"] ++
|
||||||
rsync_excludes(target) ++
|
rsync_excludes(target) ++
|
||||||
[
|
[
|
||||||
"-e",
|
"-e",
|
||||||
@@ -198,25 +198,39 @@ defmodule BDS.Publishing do
|
|||||||
files_to_upload =
|
files_to_upload =
|
||||||
filter_scp_uploads(project_id, credentials, target.kind, files_with_mtimes)
|
filter_scp_uploads(project_id, credentials, target.kind, files_with_mtimes)
|
||||||
|
|
||||||
case upload_scp_files(
|
with :ok <-
|
||||||
project_id,
|
ensure_remote_dirs(target, credentials, runner, ssh_auth_sock, files_to_upload) do
|
||||||
target,
|
do_upload_scp_files(
|
||||||
credentials,
|
project_id,
|
||||||
runner,
|
target,
|
||||||
ssh_auth_sock,
|
credentials,
|
||||||
files_to_upload,
|
runner,
|
||||||
[]
|
ssh_auth_sock,
|
||||||
) do
|
files_to_upload
|
||||||
{:ok, uploaded_files} ->
|
)
|
||||||
persist_uploaded_scp_files(project_id, credentials, target.kind, uploaded_files)
|
|
||||||
:ok
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
{:error, reason}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp do_upload_scp_files(project_id, target, credentials, runner, ssh_auth_sock, files_to_upload) do
|
||||||
|
case upload_scp_files(
|
||||||
|
project_id,
|
||||||
|
target,
|
||||||
|
credentials,
|
||||||
|
runner,
|
||||||
|
ssh_auth_sock,
|
||||||
|
files_to_upload,
|
||||||
|
[]
|
||||||
|
) do
|
||||||
|
{:ok, uploaded_files} ->
|
||||||
|
persist_uploaded_scp_files(project_id, credentials, target.kind, uploaded_files)
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp run_command(runner, command, args, ssh_auth_sock) do
|
defp run_command(runner, command, args, ssh_auth_sock) do
|
||||||
opts = command_opts(ssh_auth_sock)
|
opts = command_opts(ssh_auth_sock)
|
||||||
{output, exit_status} = runner.(command, args, opts)
|
{output, exit_status} = runner.(command, args, opts)
|
||||||
@@ -302,6 +316,25 @@ defmodule BDS.Publishing do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# scp does not create missing remote directories, so create them up front.
|
||||||
|
defp ensure_remote_dirs(_target, _credentials, _runner, _ssh_auth_sock, []), do: :ok
|
||||||
|
|
||||||
|
defp ensure_remote_dirs(target, credentials, runner, ssh_auth_sock, files_to_upload) do
|
||||||
|
remote_dirs =
|
||||||
|
files_to_upload
|
||||||
|
|> Enum.map(fn {relative_path, _local_mtime} ->
|
||||||
|
target.remote_dir |> Path.join(relative_path) |> Path.dirname()
|
||||||
|
end)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|
||||||
|
run_command(
|
||||||
|
runner,
|
||||||
|
"ssh",
|
||||||
|
[remote_base(credentials), "mkdir", "-p" | remote_dirs],
|
||||||
|
ssh_auth_sock
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp collect_file_mtimes(local_dir, files) do
|
defp collect_file_mtimes(local_dir, files) do
|
||||||
Enum.reduce_while(files, {:ok, []}, fn relative_path, {:ok, acc} ->
|
Enum.reduce_while(files, {:ok, []}, fn relative_path, {:ok, acc} ->
|
||||||
local_path = Path.join(local_dir, relative_path)
|
local_path = Path.join(local_dir, relative_path)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -3,43 +3,58 @@ defmodule BDS.Rendering do
|
|||||||
|
|
||||||
alias BDS.Rendering.ListArchive
|
alias BDS.Rendering.ListArchive
|
||||||
alias BDS.Rendering.PostRendering
|
alias BDS.Rendering.PostRendering
|
||||||
|
alias BDS.Rendering.RenderContext
|
||||||
alias BDS.Rendering.TemplateSelection
|
alias BDS.Rendering.TemplateSelection
|
||||||
|
|
||||||
def render_post_page(project_id, template_slug, assigns)
|
def render_post_page(project_id, template_slug, assigns)
|
||||||
when is_binary(project_id) and is_map(assigns) do
|
when is_binary(project_id) and is_map(assigns) do
|
||||||
|
render_post_page(RenderContext.build(project_id), template_slug, assigns)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_post_page(%RenderContext{} = ctx, template_slug, assigns) when is_map(assigns) do
|
||||||
with {:ok, template_source} <-
|
with {:ok, template_source} <-
|
||||||
TemplateSelection.load_template_source(project_id, :post, template_slug),
|
TemplateSelection.load_template_source(ctx, :post, template_slug),
|
||||||
{:ok, rendered} <-
|
{:ok, rendered} <-
|
||||||
TemplateSelection.render_template(
|
TemplateSelection.render_template(
|
||||||
project_id,
|
ctx,
|
||||||
template_source,
|
template_source,
|
||||||
PostRendering.post_assigns(project_id, assigns)
|
PostRendering.post_assigns(ctx, assigns)
|
||||||
) do
|
) do
|
||||||
{:ok, rendered}
|
{:ok, rendered}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do
|
def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do
|
||||||
with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :list, nil),
|
render_list_page(RenderContext.build(project_id), assigns)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_list_page(%RenderContext{} = ctx, assigns) when is_map(assigns) do
|
||||||
|
with {:ok, template_source} <- TemplateSelection.load_template_source(ctx, :list, nil),
|
||||||
{:ok, rendered} <-
|
{:ok, rendered} <-
|
||||||
TemplateSelection.render_template(
|
TemplateSelection.render_template(
|
||||||
project_id,
|
ctx,
|
||||||
template_source,
|
template_source,
|
||||||
ListArchive.list_assigns(project_id, assigns)
|
ListArchive.list_assigns(ctx, assigns)
|
||||||
) do
|
) do
|
||||||
{:ok, rendered}
|
{:ok, rendered}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_not_found_page(project_id, assigns \\ %{})
|
def render_not_found_page(ctx_or_project_id, assigns \\ %{})
|
||||||
|
|
||||||
|
def render_not_found_page(project_id, assigns)
|
||||||
when is_binary(project_id) and is_map(assigns) do
|
when is_binary(project_id) and is_map(assigns) do
|
||||||
|
render_not_found_page(RenderContext.build(project_id), assigns)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_not_found_page(%RenderContext{} = ctx, assigns) when is_map(assigns) do
|
||||||
with {:ok, template_source} <-
|
with {:ok, template_source} <-
|
||||||
TemplateSelection.load_template_source(project_id, :not_found, nil),
|
TemplateSelection.load_template_source(ctx, :not_found, nil),
|
||||||
{:ok, rendered} <-
|
{:ok, rendered} <-
|
||||||
TemplateSelection.render_template(
|
TemplateSelection.render_template(
|
||||||
project_id,
|
ctx,
|
||||||
template_source,
|
template_source,
|
||||||
ListArchive.not_found_assigns(project_id, assigns)
|
ListArchive.not_found_assigns(ctx, assigns)
|
||||||
) do
|
) do
|
||||||
{:ok, rendered}
|
{:ok, rendered}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -335,27 +335,40 @@ defmodule BDS.Rendering.Filters do
|
|||||||
language = macro_language(context)
|
language = macro_language(context)
|
||||||
project_id = project_id_from_context(context)
|
project_id = project_id_from_context(context)
|
||||||
|
|
||||||
{words_json, width, height} =
|
words_json = if project_id, do: build_tag_cloud_data(project_id), else: nil
|
||||||
if project_id do
|
|
||||||
build_tag_cloud_data(project_id)
|
|
||||||
else
|
|
||||||
{nil, 800, 400}
|
|
||||||
end
|
|
||||||
|
|
||||||
render_macro_template(
|
render_macro_template(
|
||||||
"macros/tag-cloud",
|
"macros/tag-cloud",
|
||||||
%{
|
%{
|
||||||
"orientation" => Map.get(params, "orientation", "horizontal"),
|
"orientation" => normalize_tag_cloud_orientation(Map.get(params, "orientation")),
|
||||||
"words_json" => words_json,
|
"words_json" => words_json,
|
||||||
"width" => Map.get(params, "width", width),
|
"width" => tag_cloud_dimension(Map.get(params, "width"), 320, 1600, 900),
|
||||||
"height" => Map.get(params, "height", height),
|
"height" => tag_cloud_dimension(Map.get(params, "height"), 180, 900, 420),
|
||||||
"aria_label" => "Tag cloud",
|
"aria_label" => BDS.Gettext.lgettext(language, "render", "Tag cloud"),
|
||||||
"empty_label" => BDS.Gettext.lgettext(language, "render", "No tags")
|
"empty_label" => BDS.Gettext.lgettext(language, "render", "No tags")
|
||||||
},
|
},
|
||||||
context
|
context
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Mirror the old app's normalizeTagCloudOrientation so `orientation=diagonal`
|
||||||
|
# (and the other aliases) map to the runtime's `mixed-diagonal`/`mixed-hv`
|
||||||
|
# rotation modes instead of silently falling back to horizontal.
|
||||||
|
defp normalize_tag_cloud_orientation(value) do
|
||||||
|
case value |> to_string() |> String.trim() |> String.downcase() do
|
||||||
|
v when v in ["mixed_hv", "mixed-hv", "hv", "horizontal_vertical"] -> "mixed-hv"
|
||||||
|
v when v in ["mixed_diagonal", "mixed-diagonal", "diagonal", "diag"] -> "mixed-diagonal"
|
||||||
|
_ -> "horizontal"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tag_cloud_dimension(value, min, max, default) do
|
||||||
|
case value |> to_string() |> Integer.parse() do
|
||||||
|
{n, _rest} when n >= min and n <= max -> n
|
||||||
|
_other -> default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp render_video_macro(kind, params, language, translation, context) do
|
defp render_video_macro(kind, params, language, translation, context) do
|
||||||
render_macro_template(
|
render_macro_template(
|
||||||
"macros/#{kind}",
|
"macros/#{kind}",
|
||||||
@@ -382,14 +395,16 @@ defmodule BDS.Rendering.Filters do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp gallery_items(post_id) do
|
defp gallery_items(post_id) do
|
||||||
|
group_name = "gallery-#{post_id}"
|
||||||
|
|
||||||
post_id
|
post_id
|
||||||
|> linked_media_images()
|
|> linked_media_images()
|
||||||
|> Enum.map(fn media ->
|
|> Enum.map(fn media ->
|
||||||
%{
|
%{
|
||||||
"media_path" => "/#{media.file_path}",
|
"media_path" => "/#{media.file_path}",
|
||||||
"title" => media.title || media.original_name,
|
"title" => media.caption || media.title || media.original_name,
|
||||||
"alt" => media.alt || media.title || media.original_name,
|
"alt" => media.alt || media.title || media.original_name,
|
||||||
"group_name" => post_id
|
"group_name" => group_name
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -403,7 +418,7 @@ defmodule BDS.Rendering.Filters do
|
|||||||
on: pm.media_id == m.id,
|
on: pm.media_id == m.id,
|
||||||
where: pm.post_id == ^post_id,
|
where: pm.post_id == ^post_id,
|
||||||
where: like(m.mime_type, "image/%"),
|
where: like(m.mime_type, "image/%"),
|
||||||
order_by: [asc: pm.sort_order, asc: pm.media_id],
|
order_by: [desc: m.created_at, asc: m.id],
|
||||||
select: m
|
select: m
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -505,6 +520,7 @@ defmodule BDS.Rendering.Filters do
|
|||||||
SELECT trim(je.value) AS tag, COUNT(*) AS cnt
|
SELECT trim(je.value) AS tag, COUNT(*) AS cnt
|
||||||
FROM posts, json_each(posts.tags) je
|
FROM posts, json_each(posts.tags) je
|
||||||
WHERE posts.project_id = ?1
|
WHERE posts.project_id = ?1
|
||||||
|
AND posts.status = 'published'
|
||||||
AND trim(je.value) != ''
|
AND trim(je.value) != ''
|
||||||
GROUP BY tag
|
GROUP BY tag
|
||||||
ORDER BY cnt DESC, lower(tag) ASC
|
ORDER BY cnt DESC, lower(tag) ASC
|
||||||
@@ -518,29 +534,66 @@ defmodule BDS.Rendering.Filters do
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
if tag_entries == [] do
|
if tag_entries == [] do
|
||||||
{nil, 0, 0}
|
nil
|
||||||
else
|
else
|
||||||
max_count = Enum.map(tag_entries, & &1.count) |> Enum.max()
|
counts = Enum.map(tag_entries, & &1.count)
|
||||||
min_count = Enum.map(tag_entries, & &1.count) |> Enum.min()
|
max_count = Enum.max(counts)
|
||||||
range = max(max_count - min_count, 1)
|
min_count = Enum.min(counts)
|
||||||
|
|
||||||
words =
|
words =
|
||||||
Enum.map(tag_entries, fn %{tag: tag, count: count, color: color} ->
|
Enum.map(tag_entries, fn %{tag: tag, count: count, color: color} ->
|
||||||
size = 12.0 + (count - min_count) / range * 28.0
|
base = %{
|
||||||
%{"text" => tag, "size" => size, "color" => color || "var(--accent-color)"}
|
"text" => tag,
|
||||||
|
"size" => tag_cloud_font_size(count, min_count, max_count),
|
||||||
|
"count" => count,
|
||||||
|
"url" => "/tag/#{URI.encode(to_string(tag), &URI.char_unreserved?/1)}/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_binary(color) and color != "", do: Map.put(base, "color", color), else: base
|
||||||
end)
|
end)
|
||||||
|
|
||||||
{Jason.encode!(words), 800, 400}
|
escape_html(Jason.encode!(words))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Match the old app's tag-cloud sizing (14px..56px, integer point sizes) so the
|
||||||
|
# d3 word-cloud runtime lays tags out identically.
|
||||||
|
defp tag_cloud_font_size(_count, min_count, max_count) when max_count == min_count,
|
||||||
|
do: round((14 + 56) / 2)
|
||||||
|
|
||||||
|
defp tag_cloud_font_size(count, min_count, max_count),
|
||||||
|
do: round(14 + (count - min_count) / (max_count - min_count) * (56 - 14))
|
||||||
|
|
||||||
|
# The tag-cloud JSON is embedded in a double-quoted HTML data attribute, so it
|
||||||
|
# must be HTML-escaped (matching the old app) or the first `"` closes the
|
||||||
|
# attribute and the client-side JSON.parse silently fails.
|
||||||
|
defp escape_html(value) do
|
||||||
|
value
|
||||||
|
|> String.replace("&", "&")
|
||||||
|
|> String.replace("<", "<")
|
||||||
|
|> String.replace(">", ">")
|
||||||
|
|> String.replace("\"", """)
|
||||||
|
|> String.replace("'", "'")
|
||||||
|
end
|
||||||
|
|
||||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp macro_language(context), do: Access.get(context, "language") || "en"
|
defp macro_language(context), do: Access.get(context, "language") || "en"
|
||||||
|
|
||||||
defp project_id_from_context(context) do
|
defp project_id_from_context(context) do
|
||||||
|
context_private_project_id(context) ||
|
||||||
|
context_post_project_id(context) ||
|
||||||
|
project_id_from_post(context)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp context_private_project_id(%Liquex.Context{private: private}) when is_map(private),
|
||||||
|
do: Map.get(private, :project_id)
|
||||||
|
|
||||||
|
defp context_private_project_id(_context), do: nil
|
||||||
|
|
||||||
|
defp context_post_project_id(context) do
|
||||||
post = Access.get(context, "post") || %{}
|
post = Access.get(context, "post") || %{}
|
||||||
post["project_id"] || Access.get(post, :project_id) || project_id_from_post(context)
|
post["project_id"] || Access.get(post, :project_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp project_id_from_post(context) do
|
defp project_id_from_post(context) do
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user