Compare commits

...

11 Commits

50 changed files with 4984 additions and 897 deletions

2
API.md
View File

@@ -150,7 +150,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**

View File

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

View File

@@ -12,6 +12,7 @@ The major architectural rework is in place.
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static). - Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP. - Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model. - Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
- The app boots in three modes: the desktop app, a headless server (SSH-served terminal UI + tunneled GUI), and a local TUI — see [Headless Server Mode And Terminal UI](#headless-server-mode-and-terminal-ui).
The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices. The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
@@ -156,6 +157,70 @@ 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 Editor
Monaco is bundled via esbuild using its ESM entry point (`monaco-editor/esm/vs/editor/editor.main.js`). The Monaco is bundled via esbuild using its ESM entry point (`monaco-editor/esm/vs/editor/editor.main.js`). The

View File

@@ -25,8 +25,13 @@ config :bds, :desktop,
window_min_size: {800, 600}, window_min_size: {800, 600},
title: "Blogging Desktop Server" title: "Blogging Desktop Server"
config :bds, :server, ssh_port: 2222
config :bds, BDS.Desktop.Endpoint, config :bds, BDS.Desktop.Endpoint,
url: [host: "127.0.0.1"], url: [host: "127.0.0.1"],
# Any-port loopback origins: the remote-GUI SSH tunnel terminates on an
# ephemeral local port, so the websocket origin port varies per connect.
check_origin: ["//127.0.0.1", "//localhost"],
adapter: Bandit.PhoenixAdapter, adapter: Bandit.PhoenixAdapter,
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false], render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
pubsub_server: BDS.PubSub, pubsub_server: BDS.PubSub,

View File

@@ -5,6 +5,22 @@ 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
# 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 +57,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(BDS.Server.mode(), current_env())
opts = [strategy: :one_for_one, name: BDS.Supervisor] opts = [strategy: :one_for_one, name: BDS.Supervisor]
Supervisor.start_link(children, opts) Supervisor.start_link(children, opts)
@@ -77,7 +93,8 @@ 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

View File

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

View File

@@ -23,7 +23,8 @@ defmodule BDS.Desktop.Endpoint do
plug(BDS.Desktop.Router) plug(BDS.Desktop.Router)
defp maybe_require_desktop_auth(conn, _opts) do defp maybe_require_desktop_auth(conn, _opts) do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] or
not BDS.Server.desktop_auth_required?() do
conn conn
else else
Desktop.Auth.call(conn, []) Desktop.Auth.call(conn, [])

View File

@@ -67,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}
@@ -186,6 +215,8 @@ defmodule BDS.Desktop.MenuBar do
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")

View File

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

View File

@@ -91,14 +91,16 @@ defmodule BDS.Desktop.ShellData do
end end
end end
def editor_meta(task_status) do # Accepts the caller's already-resolved locale so hot render paths never
# re-read the ui.language setting from the database.
def editor_meta(task_status, ui_language \\ nil) do
[ [
%{ %{
label: dgettext("ui", "Status"), 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
@@ -107,7 +109,7 @@ defmodule BDS.Desktop.ShellData do
post_count: dashboard.post_stats.total_posts, post_count: dashboard.post_stats.total_posts,
media_count: dashboard.media_stats.media_count, media_count: dashboard.media_stats.media_count,
theme_badge: "desktop-shell", theme_badge: "desktop-shell",
ui_language: Keyword.get(opts, :ui_language, ui_language()), ui_language: Keyword.get_lazy(opts, :ui_language, &ui_language/0),
offline_mode: Keyword.get(opts, :offline_mode, true), offline_mode: Keyword.get(opts, :offline_mode, true),
running_task_message: task_status.running_task_message, running_task_message: task_status.running_task_message,
running_task_overflow: task_status.running_task_overflow, running_task_overflow: task_status.running_task_overflow,

View File

@@ -6,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.{
@@ -109,7 +108,9 @@ defmodule BDS.Desktop.ShellLive do
:quit, :quit,
:view_on_github, :view_on_github,
:report_issue, :report_issue,
:about :about,
:connect_server,
:disconnect_server
]) ])
@runtime_menu_actions MapSet.new([ @runtime_menu_actions MapSet.new([
:undo, :undo,
@@ -151,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
@@ -736,6 +739,14 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, assign(socket, :shell_overlay, overlay)} {:noreply, assign(socket, :shell_overlay, overlay)}
end 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
@@ -981,6 +992,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)
@@ -1079,6 +1093,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,

View File

@@ -266,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.Bridges do
socket socket
|> assign(:task_status, task_status) |> assign(:task_status, task_status)
|> assign(:editor_meta, ShellData.editor_meta(task_status)) |> assign(:editor_meta, ShellData.editor_meta(task_status, socket.assigns.page_language))
|> assign( |> assign(
:status, :status,
ShellData.status_bar( ShellData.status_bar(

View File

@@ -6,11 +6,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
alias BDS.{AI, Embeddings, Metadata, Posts, Preview, Tasks} 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: [
editing_canonical_language?: 3, editing_canonical_language?: 3,
normalize_language: 2, normalize_language: 2,
@@ -48,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,

View File

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

View File

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

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

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

View File

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

View File

@@ -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
@@ -121,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} ->
@@ -162,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} ->
@@ -211,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} ->

View File

@@ -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,6 +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 = Events.entity_changed("post", post.id, :created)
{:ok, post} {:ok, post}
error -> error ->
@@ -151,6 +153,7 @@ defmodule BDS.Posts do
:ok = AutoTranslation.maybe_schedule(updated_post) :ok = AutoTranslation.maybe_schedule(updated_post)
end end
:ok = Events.entity_changed("post", updated_post.id, :updated)
{:ok, updated_post} {:ok, updated_post}
error -> error ->
@@ -202,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 ->
@@ -335,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} ->

View File

@@ -38,7 +38,8 @@ defmodule BDS.Scripting.ApiDocs do
%{ %{
module: "app", module: "app",
name: "get_system_language", name: "get_system_language",
description: "Return the current UI locale.", description:
"Return the current UI locale (the server-side UI language setting, falling back to the OS locale when unset).",
params: [], params: [],
returns: "string | nil" returns: "string | nil"
}, },

View File

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

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

@@ -0,0 +1,111 @@
defmodule BDS.Server do
@moduledoc """
Headless server mode (issue #26).
Boots the app without any wx window and serves clients over a single SSH
daemon: TUI sessions run server-side via `ExRatatui.SSH` (only terminal
cells cross the wire), and GUI clients tunnel to the loopback HTTP
endpoint through the same daemon (`tcpip_tunnel_out`). Authentication is
public-key only; host key and `authorized_keys` live in the private app
data dir next to the database — never in the repo or a priv dir.
"""
@doc """
Resolves the boot mode from the `BDS_MODE` environment variable:
`"server"` (headless + SSH daemon), `"tui"` (headless + SSH daemon +
a TUI attached to the launching terminal), anything else is desktop.
"""
@spec mode(String.t() | nil) :: :desktop | :server | :tui
def mode(value \\ System.get_env("BDS_MODE"))
def mode(value) when is_binary(value) do
case String.downcase(value) do
"server" -> :server
"tui" -> :tui
_other -> :desktop
end
end
def mode(nil), do: :desktop
@doc """
Whether the HTTP endpoint requires the desktop webview auth token. Only
desktop mode does: in server/tui mode the endpoint stays loopback-only
and clients arrive through the key-authenticated SSH tunnel, which the
per-boot webview token would otherwise lock out.
"""
@spec desktop_auth_required?(:desktop | :server | :tui) :: boolean()
def desktop_auth_required?(mode \\ mode())
def desktop_auth_required?(:desktop), do: true
def desktop_auth_required?(_mode), do: false
@doc "SSH key-material directory: `<data dir>/ssh` next to the database."
@spec ssh_dir() :: Path.t()
def ssh_dir do
Application.get_env(:bds, BDS.Repo)[:database]
|> Path.dirname()
|> Path.join("ssh")
end
@doc """
Ensures the SSH directory exists with an `authorized_keys` file (created
empty and chmod 600 on first boot; existing keys are never touched).
"""
@spec ensure_ssh_dir!(Path.t()) :: Path.t()
def ensure_ssh_dir!(dir) do
File.mkdir_p!(dir)
keys_path = Path.join(dir, "authorized_keys")
unless File.exists?(keys_path) do
File.write!(keys_path, "")
File.chmod!(keys_path, 0o600)
end
dir
end
@doc """
Options for `ExRatatui.SSH.Daemon`: serves `BDS.TUI`, public-key auth
only, and allows TCP/IP tunneling out so GUI clients can reach the
loopback HTTP endpoint through the SSH connection.
"""
@spec daemon_opts(Path.t()) :: keyword()
def daemon_opts(dir \\ ssh_dir()) do
dir = ensure_ssh_dir!(dir)
system_dir = ExRatatui.SSH.Daemon.ensure_host_key!(dir)
[
mod: BDS.TUI,
port: ssh_port(),
system_dir: system_dir,
user_dir: String.to_charlist(dir),
auth_methods: ~c"publickey",
tcpip_tunnel_out: true
]
end
@doc "SSH listen port: `BDS_SSH_PORT` env or `:bds, :server` config."
@spec ssh_port() :: pos_integer()
def ssh_port do
case System.get_env("BDS_SSH_PORT") do
value when is_binary(value) and value != "" -> String.to_integer(value)
_other -> Application.get_env(:bds, :server)[:ssh_port] || 2222
end
end
@doc false
# Child spec that defers all filesystem work (key generation) to process
# start, so building a children list never touches the data dir.
def child_spec(_opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, []},
type: :worker
}
end
@doc false
def start_link do
ExRatatui.SSH.Daemon.start_link(daemon_opts())
end
end

View File

@@ -27,8 +27,12 @@ defmodule BDS.Settings do
}) })
|> Repo.insert_or_update() |> Repo.insert_or_update()
|> case do |> case do
{:ok, _setting} -> :ok {:ok, _setting} ->
{:error, reason} -> {:error, reason} :ok = BDS.Events.settings_changed(key)
:ok
{:error, reason} ->
{:error, reason}
end end
end end
end end

View File

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

View File

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

1064
lib/bds/tui.ex Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,107 @@
defmodule BDS.UI.PostEditor.Draft do
@moduledoc """
Pure draft/form logic for the post editor (issue #26, phase 3).
Shared by the LiveView shell and the TUI: builds the persisted form for a
post (canonical language or translation), normalizes editor params, and
provides the small pure helpers both renderers need. No socket, no
process state — callers own the draft maps.
"""
alias BDS.Posts
alias BDS.Posts.{Post, Translation}
alias BDS.UI.PostEditor.Metadata
def normalize_mode(mode) when mode in [:markdown, :preview], do: mode
def normalize_mode("visual"), do: :markdown
def normalize_mode("preview"), do: :preview
def normalize_mode(_mode), do: :markdown
def normalize_language(value, fallback) do
case value |> to_string() |> String.trim() do
"" -> fallback
normalized -> String.downcase(normalized)
end
end
def normalize_params(params, current_language, next_language) do
%{
"title" => Map.get(params, "title", ""),
"excerpt" => Map.get(params, "excerpt", ""),
"content" => Map.get(params, "content", ""),
"tags" => Map.get(params, "tags", ""),
"categories" => Map.get(params, "categories", ""),
"author" => Map.get(params, "author", ""),
"language" =>
if(current_language == next_language,
do: normalize_language(Map.get(params, "language"), current_language),
else: next_language
),
"do_not_translate" => truthy?(Map.get(params, "do_not_translate")),
"template_slug" => Map.get(params, "template_slug", "")
}
end
def persisted_form(%Post{} = post, metadata, active_language) do
persisted_form(post, metadata, active_language, Metadata.translations(post.id))
end
def persisted_form(post, metadata, active_language, translations) do
canonical_language = Metadata.canonical_language(post, metadata)
translation = Map.get(translations, active_language)
if active_language == canonical_language do
%{
"title" => post.title || "",
"excerpt" => post.excerpt || "",
"content" => Posts.editor_body(post),
"tags" => Enum.join(post.tags || [], ", "),
"categories" => Enum.join(post.categories || [], ", "),
"author" => post.author || metadata.default_author || "",
"language" => canonical_language,
"do_not_translate" => post.do_not_translate || false,
"template_slug" => post.template_slug || ""
}
else
%{
"title" => (translation && translation.title) || "",
"excerpt" => (translation && translation.excerpt) || "",
"content" => if(translation, do: Posts.editor_body(translation), else: ""),
"tags" => Enum.join(post.tags || [], ", "),
"categories" => Enum.join(post.categories || [], ", "),
"author" => post.author || metadata.default_author || "",
"language" => active_language,
"do_not_translate" => post.do_not_translate || false,
"template_slug" => post.template_slug || ""
}
end
end
def save_state_for_action(:publish), do: :published
def save_state_for_action(_action), do: :saved
def record_title(%Translation{title: title}, post),
do: blank_to_nil(title) || post.title || post.slug || post.id
def record_title(%Post{title: title, slug: slug, id: id}, _post),
do: blank_to_nil(title) || blank_to_nil(slug) || id
def record_status(%Translation{status: status}), do: status || :draft
def record_status(%Post{status: status}), do: status || :draft
def editing_canonical_language?(translations, active_language, canonical_language) do
active_language == canonical_language or not Map.has_key?(translations, active_language)
end
defp truthy?(value), do: BDS.Values.truthy?(value)
defp blank_to_nil(value) do
value
|> to_string()
|> String.trim()
|> case do
"" -> nil
trimmed -> trimmed
end
end
end

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ defmodule BDS.MixProject do
{:live_toast, "~> 0.8.0"}, {:live_toast, "~> 0.8.0"},
{:saxy, "~> 1.4"}, {:saxy, "~> 1.4"},
{:desktop, "~> 1.5"}, {:desktop, "~> 1.5"},
{:ex_ratatui, "~> 0.11"},
{:image, "~> 0.67"}, {:image, "~> 0.67"},
{:nx, "~> 0.10"}, {:nx, "~> 0.10"},
{:exla, "~> 0.10"}, {:exla, "~> 0.10"},

View File

@@ -25,6 +25,7 @@
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"}, "ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
"ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"},
"ex_ratatui": {:hex, :ex_ratatui, "0.11.1", "f3cc5c9827d957bc326d3b3b48338a927716fedc2575311d65a3af0dd03ed951", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b37b02b33e1e8f49874497d7b7f8872417b3807ec77d4bc1526c07c05c7e4cc9"},
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"}, "ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"}, "ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
"exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"}, "exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"},

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ msgstr ""
msgid "%{count} mapped" msgid "%{count} mapped"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:171 #: lib/bds/desktop/shell_data.ex:173
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "%{count} post" msgid "%{count} post"
msgid_plural "%{count} posts" msgid_plural "%{count} posts"
@@ -71,8 +71,8 @@ msgid "AI Assistant"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:93 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:93
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:190 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:193
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:208 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:211
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "AI Settings" msgid "AI Settings"
msgstr "" msgstr ""
@@ -81,6 +81,11 @@ msgstr ""
#: lib/bds/desktop/shell_live/overlay_manager.ex:72 #: lib/bds/desktop/shell_live/overlay_manager.ex:72
#: lib/bds/desktop/shell_live/post_editor.ex:920 #: lib/bds/desktop/shell_live/post_editor.ex:920
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:43 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:43
#: lib/bds/tui.ex:373
#: lib/bds/tui.ex:376
#: lib/bds/tui.ex:379
#: lib/bds/tui.ex:387
#: lib/bds/tui.ex:922
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "AI Suggestions" msgid "AI Suggestions"
msgstr "" msgstr ""
@@ -105,12 +110,12 @@ msgstr ""
msgid "API" msgid "API"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:234 #: lib/bds/desktop/menu_bar.ex:265
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "API Documentation" msgid "API Documentation"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:232 #: lib/bds/desktop/menu_bar.ex:263
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "About" msgid "About"
msgstr "" msgstr ""
@@ -301,7 +306,7 @@ msgstr ""
msgid "Validation apply prepared" msgid "Validation apply prepared"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:164 #: lib/bds/desktop/shell_data.ex:166
#: lib/bds/ui/sidebar.ex:331 #: lib/bds/ui/sidebar.ex:331
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archived" msgid "Archived"
@@ -336,7 +341,7 @@ msgstr ""
msgid "Auto" msgid "Auto"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:448 #: lib/bds/desktop/shell_live.ex:445
#: lib/bds/desktop/shell_live/chat_editor.ex:234 #: lib/bds/desktop/shell_live/chat_editor.ex:234
#: lib/bds/desktop/shell_live/media_editor.ex:171 #: lib/bds/desktop/shell_live/media_editor.ex:171
#: lib/bds/desktop/shell_live/media_editor.ex:364 #: lib/bds/desktop/shell_live/media_editor.ex:364
@@ -367,7 +372,7 @@ msgstr ""
msgid "Backlinks" msgid "Backlinks"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:181 #: lib/bds/desktop/menu_bar.ex:210
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Blog" msgid "Blog"
msgstr "" msgstr ""
@@ -486,7 +491,7 @@ msgstr ""
msgid "Category name is required" msgid "Category name is required"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:771 #: lib/bds/desktop/shell_live.ex:791
#: lib/bds/desktop/shell_live/chat_editor.ex:87 #: lib/bds/desktop/shell_live/chat_editor.ex:87
#: lib/bds/desktop/shell_live/chat_editor.ex:233 #: lib/bds/desktop/shell_live/chat_editor.ex:233
#: lib/bds/desktop/shell_live/chat_editor.ex:323 #: lib/bds/desktop/shell_live/chat_editor.ex:323
@@ -544,7 +549,7 @@ msgstr ""
msgid "Click to %{action} mapping" msgid "Click to %{action} mapping"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:190 #: lib/bds/desktop/menu_bar.ex:221
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Close Tab" msgid "Close Tab"
msgstr "" msgstr ""
@@ -567,6 +572,7 @@ msgid "Collapse unchanged diff hunks"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/overlay_manager.ex:422 #: lib/bds/desktop/shell_live/overlay_manager.ex:422
#: lib/bds/tui.ex:1017
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Command completed" msgid "Command completed"
msgstr "" msgstr ""
@@ -584,6 +590,7 @@ msgstr ""
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:375 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:375
#: lib/bds/desktop/shell_live/script_editor_html/script_editor.html.heex:34 #: lib/bds/desktop/shell_live/script_editor_html/script_editor.html.heex:34
#: lib/bds/desktop/shell_live/template_editor_html/template_editor.html.heex:32 #: lib/bds/desktop/shell_live/template_editor_html/template_editor.html.heex:32
#: lib/bds/tui.ex:550
#: lib/bds/ui/sidebar.ex:764 #: lib/bds/ui/sidebar.ex:764
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Content" msgid "Content"
@@ -594,7 +601,7 @@ msgstr ""
msgid "Content Categories" msgid "Content Categories"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:195 #: lib/bds/desktop/menu_bar.ex:226
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Copy" msgid "Copy"
msgstr "" msgstr ""
@@ -647,7 +654,7 @@ msgstr ""
msgid "Created" msgid "Created"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:194 #: lib/bds/desktop/menu_bar.ex:225
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cut" msgid "Cut"
msgstr "" msgstr ""
@@ -713,7 +720,7 @@ msgstr ""
msgid "Default editing mode and diff presentation" msgid "Default editing mode and diff presentation"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:197 #: lib/bds/desktop/menu_bar.ex:228
#: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:98 #: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:98
#: lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex:166 #: lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex:166
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:116 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:116
@@ -753,7 +760,7 @@ msgstr ""
msgid "Delete conversation" msgid "Delete conversation"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:65 #: lib/bds/ui/post_editor/persistence.ex:69
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete this unpublished draft" msgid "Delete this unpublished draft"
msgstr "" msgstr ""
@@ -810,17 +817,17 @@ msgstr ""
msgid "Disable reasoning output for the online chat model" msgid "Disable reasoning output for the online chat model"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:57 #: lib/bds/ui/post_editor/persistence.ex:61
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Discard Changes" msgid "Discard Changes"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:58 #: lib/bds/ui/post_editor/persistence.ex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Discard Draft" msgid "Discard Draft"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:64 #: lib/bds/ui/post_editor/persistence.ex:68
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Discard changes and restore the published version" msgid "Discard changes and restore the published version"
msgstr "" msgstr ""
@@ -856,14 +863,14 @@ msgstr ""
msgid "Do Not Translate" msgid "Do Not Translate"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:233 #: lib/bds/desktop/menu_bar.ex:264
#: lib/bds/help_docs.ex:14 #: lib/bds/help_docs.ex:14
#: lib/bds/ui/registry.ex:118 #: lib/bds/ui/registry.ex:118
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Documentation" msgid "Documentation"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:162 #: lib/bds/desktop/shell_data.ex:164
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Draft" msgid "Draft"
msgstr "" msgstr ""
@@ -889,12 +896,12 @@ msgstr ""
msgid "ETA: %{value}" msgid "ETA: %{value}"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:179 #: lib/bds/desktop/menu_bar.ex:208
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:216 #: lib/bds/desktop/menu_bar.ex:247
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit Menu" msgid "Edit Menu"
msgstr "" msgstr ""
@@ -1000,7 +1007,7 @@ msgstr ""
msgid "Extra URLs" msgid "Extra URLs"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:178 #: lib/bds/desktop/menu_bar.ex:207
#: lib/bds/desktop/shell_live/import_editor.ex:882 #: lib/bds/desktop/shell_live/import_editor.ex:882
#: lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex:157 #: lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex:157
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -1022,17 +1029,19 @@ msgstr ""
msgid "Filename" msgid "Filename"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:223 #: lib/bds/desktop/menu_bar.ex:254
#: lib/bds/tui.ex:981
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Fill Missing Translations" msgid "Fill Missing Translations"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:199 #: lib/bds/desktop/menu_bar.ex:230
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Find" msgid "Find"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:224 #: lib/bds/desktop/menu_bar.ex:255
#: lib/bds/tui.ex:984
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Find Duplicate Posts" msgid "Find Duplicate Posts"
msgstr "" msgstr ""
@@ -1047,7 +1056,7 @@ msgstr ""
msgid "Find Duplicates" msgid "Find Duplicates"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:209 #: lib/bds/desktop/menu_bar.ex:240
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Force Reload" msgid "Force Reload"
msgstr "" msgstr ""
@@ -1058,12 +1067,13 @@ msgstr ""
msgid "Gallery" msgid "Gallery"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:225 #: lib/bds/desktop/menu_bar.ex:256
#: lib/bds/tui.ex:965
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Generate Site" msgid "Generate Site"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:772 #: lib/bds/desktop/shell_live.ex:792
#: lib/bds/desktop/shell_live/socket_state.ex:109 #: lib/bds/desktop/shell_live/socket_state.ex:109
#: lib/bds/ui/sidebar.ex:789 #: lib/bds/ui/sidebar.ex:789
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -1076,14 +1086,14 @@ msgstr ""
msgid "Git Diff" msgid "Git Diff"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:226 #: lib/bds/desktop/shell_data.ex:228
#: lib/bds/desktop/shell_live.ex:768 #: lib/bds/desktop/shell_live.ex:788
#: lib/bds/desktop/shell_live/panel_renderer.ex:171 #: lib/bds/desktop/shell_live/panel_renderer.ex:171
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Git Log" msgid "Git Log"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:183 #: lib/bds/desktop/menu_bar.ex:212
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Help" msgid "Help"
msgstr "" msgstr ""
@@ -1103,7 +1113,7 @@ msgstr ""
msgid "Host" msgid "Host"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:98 #: lib/bds/desktop/shell_data.ex:100
#: lib/bds/desktop/shell_live/index.html.heex:657 #: lib/bds/desktop/shell_live/index.html.heex:657
#: lib/bds/desktop/shell_live/media_editor.ex:731 #: lib/bds/desktop/shell_live/media_editor.ex:731
#: lib/bds/desktop/shell_live/post_editor.ex:1038 #: lib/bds/desktop/shell_live/post_editor.ex:1038
@@ -1170,7 +1180,7 @@ msgstr ""
msgid "Import (new slug)" msgid "Import (new slug)"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:186 #: lib/bds/desktop/menu_bar.ex:215
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import Media" msgid "Import Media"
msgstr "" msgstr ""
@@ -1200,9 +1210,9 @@ msgstr ""
msgid "Import failed: %{error}" msgid "Import failed: %{error}"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:598 #: lib/bds/desktop/shell_live.ex:608
#: lib/bds/desktop/shell_live.ex:884 #: lib/bds/desktop/shell_live.ex:904
#: lib/bds/desktop/shell_live.ex:890 #: lib/bds/desktop/shell_live.ex:910
#: lib/bds/desktop/shell_live/sidebar_create.ex:47 #: lib/bds/desktop/shell_live/sidebar_create.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import media" msgid "Import media"
@@ -1337,7 +1347,7 @@ msgstr ""
msgid "Macros (%{count})" msgid "Macros (%{count})"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:101 #: lib/bds/desktop/shell_data.ex:103
#: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:55 #: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Main Language" msgid "Main Language"
@@ -1372,13 +1382,16 @@ msgstr ""
msgid "Max Posts Per Page" msgid "Max Posts Per Page"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:203 #: lib/bds/desktop/menu_bar.ex:234
#: lib/bds/desktop/shell_live/media_editor.ex:509 #: lib/bds/desktop/shell_live/media_editor.ex:509
#: lib/bds/desktop/shell_live/media_editor.ex:513 #: lib/bds/desktop/shell_live/media_editor.ex:513
#: lib/bds/desktop/shell_live/misc_editor.ex:735 #: lib/bds/desktop/shell_live/misc_editor.ex:735
#: lib/bds/desktop/shell_live/misc_editor.ex:762 #: lib/bds/desktop/shell_live/misc_editor.ex:762
#: lib/bds/desktop/shell_live/sidebar_components.ex:654 #: lib/bds/desktop/shell_live/sidebar_components.ex:654
#: lib/bds/desktop/shell_live/sidebar_delete.ex:175 #: lib/bds/desktop/shell_live/sidebar_delete.ex:175
#: lib/bds/tui.ex:836
#: lib/bds/tui.ex:1046
#: lib/bds/tui.ex:1049
#: lib/bds/ui/registry.ex:30 #: lib/bds/ui/registry.ex:30
#: lib/bds/ui/registry.ex:100 #: lib/bds/ui/registry.ex:100
#: lib/bds/ui/sidebar.ex:558 #: lib/bds/ui/sidebar.ex:558
@@ -1417,10 +1430,15 @@ msgstr ""
msgid "Metadata" msgid "Metadata"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:220 #: lib/bds/desktop/menu_bar.ex:251
#: lib/bds/desktop/shell_live/misc_editor.ex:214 #: lib/bds/desktop/shell_live/misc_editor.ex:214
#: lib/bds/desktop/shell_live/misc_editor.ex:226 #: lib/bds/desktop/shell_live/misc_editor.ex:226
#: lib/bds/desktop/shell_live/misc_editor.ex:448 #: lib/bds/desktop/shell_live/misc_editor.ex:448
#: lib/bds/tui.ex:193
#: lib/bds/tui.ex:199
#: lib/bds/tui.ex:210
#: lib/bds/tui.ex:654
#: lib/bds/tui.ex:962
#: lib/bds/ui/registry.ex:111 #: lib/bds/ui/registry.ex:111
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Metadata Diff" msgid "Metadata Diff"
@@ -1431,7 +1449,7 @@ msgstr ""
msgid "Missing URLs" msgid "Missing URLs"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:100 #: lib/bds/desktop/shell_data.ex:102
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Mode" msgid "Mode"
msgstr "" msgstr ""
@@ -1464,8 +1482,9 @@ msgstr ""
msgid "New Page" msgid "New Page"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:185 #: lib/bds/desktop/menu_bar.ex:214
#: lib/bds/desktop/shell_live/sidebar_create.ex:41 #: lib/bds/desktop/shell_live/sidebar_create.ex:41
#: lib/bds/tui.ex:150
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "New Post" msgid "New Post"
msgstr "" msgstr ""
@@ -1644,7 +1663,7 @@ msgstr ""
msgid "Nothing to Import" msgid "Nothing to Import"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:100 #: lib/bds/desktop/shell_data.ex:102
#: lib/bds/desktop/shell_live/index.html.heex:503 #: lib/bds/desktop/shell_live/index.html.heex:503
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Offline" msgid "Offline"
@@ -1736,7 +1755,7 @@ msgstr ""
msgid "Open" msgid "Open"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:189 #: lib/bds/desktop/menu_bar.ex:220
#: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:382 #: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:382
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open Data Folder" msgid "Open Data Folder"
@@ -1753,7 +1772,8 @@ msgstr ""
msgid "Open Settings" msgid "Open Settings"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:188 #: lib/bds/desktop/menu_bar.ex:217
#: lib/bds/tui.ex:986
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open in Browser" msgid "Open in Browser"
msgstr "" msgstr ""
@@ -1784,7 +1804,7 @@ msgstr ""
msgid "Other (%{count})" msgid "Other (%{count})"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:767 #: lib/bds/desktop/shell_live.ex:787
#: lib/bds/desktop/shell_live/panel_renderer.ex:83 #: lib/bds/desktop/shell_live/panel_renderer.ex:83
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Output" msgid "Output"
@@ -1832,7 +1852,7 @@ msgstr ""
msgid "Parsing WXR file..." msgid "Parsing WXR file..."
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:196 #: lib/bds/desktop/menu_bar.ex:227
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paste" msgid "Paste"
msgstr "" msgstr ""
@@ -1866,7 +1886,7 @@ msgstr ""
msgid "Post" msgid "Post"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:229 #: lib/bds/desktop/shell_data.ex:231
#: lib/bds/desktop/shell_live/panel_renderer.ex:118 #: lib/bds/desktop/shell_live/panel_renderer.ex:118
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:310 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:310
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -1898,9 +1918,11 @@ msgstr ""
msgid "Post saved" msgid "Post saved"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:202 #: lib/bds/desktop/menu_bar.ex:233
#: lib/bds/desktop/shell_live/misc_editor.ex:664 #: lib/bds/desktop/shell_live/misc_editor.ex:664
#: lib/bds/desktop/shell_live/misc_editor.ex:761 #: lib/bds/desktop/shell_live/misc_editor.ex:761
#: lib/bds/tui.ex:835
#: lib/bds/tui.ex:846
#: lib/bds/ui/registry.ex:14 #: lib/bds/ui/registry.ex:14
#: lib/bds/ui/sidebar.ex:271 #: lib/bds/ui/sidebar.ex:271
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -1912,7 +1934,7 @@ msgstr ""
msgid "Posts (%{count})" msgid "Posts (%{count})"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:201 #: lib/bds/desktop/menu_bar.ex:232
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Preferences" msgid "Preferences"
msgstr "" msgstr ""
@@ -1928,7 +1950,7 @@ msgstr ""
msgid "Preview Mode" msgid "Preview Mode"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:215 #: lib/bds/desktop/menu_bar.ex:246
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Preview Post" msgid "Preview Post"
msgstr "" msgstr ""
@@ -1980,12 +2002,12 @@ msgstr ""
msgid "Publish" msgid "Publish"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:214 #: lib/bds/desktop/menu_bar.ex:245
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Publish Selected" msgid "Publish Selected"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:163 #: lib/bds/desktop/shell_data.ex:165
#: lib/bds/desktop/shell_live/post_editor.ex:1036 #: lib/bds/desktop/shell_live/post_editor.ex:1036
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:472 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:472
#: lib/bds/ui/sidebar.ex:324 #: lib/bds/ui/sidebar.ex:324
@@ -2013,7 +2035,7 @@ msgstr ""
msgid "Quick Actions" msgid "Quick Actions"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:191 #: lib/bds/desktop/menu_bar.ex:222
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Quit" msgid "Quit"
msgstr "" msgstr ""
@@ -2023,13 +2045,15 @@ msgstr ""
msgid "Ready to import:" msgid "Ready to import:"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:217 #: lib/bds/desktop/menu_bar.ex:248
#: lib/bds/tui.ex:966
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Rebuild Database" msgid "Rebuild Database"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:219 #: lib/bds/desktop/menu_bar.ex:250
#: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:381 #: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:381
#: lib/bds/tui.ex:970
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Rebuild Embedding Index" msgid "Rebuild Embedding Index"
msgstr "" msgstr ""
@@ -2059,7 +2083,7 @@ msgstr ""
msgid "Rebuild filesystem-backed records and thumbnails" msgid "Rebuild filesystem-backed records and thumbnails"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:193 #: lib/bds/desktop/menu_bar.ex:224
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Redo" msgid "Redo"
msgstr "" msgstr ""
@@ -2086,7 +2110,8 @@ msgstr ""
msgid "Refresh Translation" msgid "Refresh Translation"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:221 #: lib/bds/desktop/menu_bar.ex:252
#: lib/bds/tui.ex:973
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Regenerate Calendar" msgid "Regenerate Calendar"
msgstr "" msgstr ""
@@ -2096,12 +2121,13 @@ msgstr ""
msgid "Regenerate Missing Thumbnails" msgid "Regenerate Missing Thumbnails"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:218 #: lib/bds/desktop/menu_bar.ex:249
#: lib/bds/tui.ex:967
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reindex Text" msgid "Reindex Text"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:208 #: lib/bds/desktop/menu_bar.ex:239
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reload" msgid "Reload"
msgstr "" msgstr ""
@@ -2132,7 +2158,7 @@ msgstr ""
msgid "Render in Lists" msgid "Render in Lists"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:200 #: lib/bds/desktop/menu_bar.ex:231
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Replace" msgid "Replace"
msgstr "" msgstr ""
@@ -2149,12 +2175,12 @@ msgstr ""
msgid "Replace Media File" msgid "Replace Media File"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:236 #: lib/bds/desktop/menu_bar.ex:267
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Report Issue" msgid "Report Issue"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:210 #: lib/bds/desktop/menu_bar.ex:241
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reset Zoom" msgid "Reset Zoom"
msgstr "" msgstr ""
@@ -2209,7 +2235,7 @@ msgstr ""
msgid "SSH Mode" msgid "SSH Mode"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:187 #: lib/bds/desktop/menu_bar.ex:216
#: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:89 #: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:89
#: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:299 #: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:299
#: lib/bds/desktop/shell_live/script_editor_html/script_editor.html.heex:13 #: lib/bds/desktop/shell_live/script_editor_html/script_editor.html.heex:13
@@ -2282,6 +2308,7 @@ msgstr ""
#: lib/bds/desktop/shell_live/script_editor.ex:216 #: lib/bds/desktop/shell_live/script_editor.ex:216
#: lib/bds/desktop/shell_live/script_editor.ex:219 #: lib/bds/desktop/shell_live/script_editor.ex:219
#: lib/bds/desktop/shell_live/script_editor.ex:234 #: lib/bds/desktop/shell_live/script_editor.ex:234
#: lib/bds/tui.ex:838
#: lib/bds/ui/registry.ex:38 #: lib/bds/ui/registry.ex:38
#: lib/bds/ui/sidebar.ex:63 #: lib/bds/ui/sidebar.ex:63
#: lib/bds/ui/sidebar.ex:115 #: lib/bds/ui/sidebar.ex:115
@@ -2327,7 +2354,7 @@ msgstr ""
msgid "Select & Analyze" msgid "Select & Analyze"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:198 #: lib/bds/desktop/menu_bar.ex:229
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
@@ -2409,6 +2436,9 @@ msgid "Site"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/misc_editor.ex:412 #: lib/bds/desktop/shell_live/misc_editor.ex:412
#: lib/bds/tui.ex:226
#: lib/bds/tui.ex:228
#: lib/bds/tui.ex:659
#: lib/bds/ui/registry.ex:125 #: lib/bds/ui/registry.ex:125
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Site Validation" msgid "Site Validation"
@@ -2456,7 +2486,7 @@ msgstr ""
msgid "Starting..." msgid "Starting..."
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:97 #: lib/bds/desktop/shell_data.ex:99
#: lib/bds/desktop/shell_live/import_editor.ex:1182 #: lib/bds/desktop/shell_live/import_editor.ex:1182
#: lib/bds/desktop/shell_live/import_editor.ex:1233 #: lib/bds/desktop/shell_live/import_editor.ex:1233
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -2529,6 +2559,7 @@ msgstr ""
#: lib/bds/desktop/shell_live/tags_editor.ex:222 #: lib/bds/desktop/shell_live/tags_editor.ex:222
#: lib/bds/desktop/shell_live/tags_editor.ex:253 #: lib/bds/desktop/shell_live/tags_editor.ex:253
#: lib/bds/desktop/shell_live/tags_editor_html/tags_editor.html.heex:11 #: lib/bds/desktop/shell_live/tags_editor_html/tags_editor.html.heex:11
#: lib/bds/tui.ex:839
#: lib/bds/ui/registry.ex:54 #: lib/bds/ui/registry.ex:54
#: lib/bds/ui/registry.ex:103 #: lib/bds/ui/registry.ex:103
#: lib/bds/ui/sidebar.ex:289 #: lib/bds/ui/sidebar.ex:289
@@ -2538,8 +2569,9 @@ msgstr ""
msgid "Tags" msgid "Tags"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:766 #: lib/bds/desktop/shell_live.ex:786
#: lib/bds/desktop/shell_live/panel_renderer.ex:54 #: lib/bds/desktop/shell_live/panel_renderer.ex:54
#: lib/bds/tui.ex:403
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Tasks" msgid "Tasks"
msgstr "" msgstr ""
@@ -2584,6 +2616,7 @@ msgstr ""
#: lib/bds/desktop/shell_live/template_editor.ex:171 #: lib/bds/desktop/shell_live/template_editor.ex:171
#: lib/bds/desktop/shell_live/template_editor.ex:176 #: lib/bds/desktop/shell_live/template_editor.ex:176
#: lib/bds/desktop/shell_live/template_editor.ex:191 #: lib/bds/desktop/shell_live/template_editor.ex:191
#: lib/bds/tui.ex:837
#: lib/bds/ui/registry.ex:46 #: lib/bds/ui/registry.ex:46
#: lib/bds/ui/sidebar.ex:71 #: lib/bds/ui/sidebar.ex:71
#: lib/bds/ui/sidebar.ex:122 #: lib/bds/ui/sidebar.ex:122
@@ -2633,12 +2666,12 @@ msgstr ""
msgid "Title" msgid "Title"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:206 #: lib/bds/desktop/menu_bar.ex:237
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle Assistant Sidebar" msgid "Toggle Assistant Sidebar"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:207 #: lib/bds/desktop/menu_bar.ex:238
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle Dev Tools" msgid "Toggle Dev Tools"
msgstr "" msgstr ""
@@ -2649,17 +2682,17 @@ msgstr ""
msgid "Toggle Filters" msgid "Toggle Filters"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:213 #: lib/bds/desktop/menu_bar.ex:244
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle Full Screen" msgid "Toggle Full Screen"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:205 #: lib/bds/desktop/menu_bar.ex:236
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle Panel" msgid "Toggle Panel"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:204 #: lib/bds/desktop/menu_bar.ex:235
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle Sidebar" msgid "Toggle Sidebar"
msgstr "" msgstr ""
@@ -2755,7 +2788,7 @@ msgstr ""
msgid "URL" msgid "URL"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:192 #: lib/bds/desktop/menu_bar.ex:223
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Undo" msgid "Undo"
msgstr "" msgstr ""
@@ -2780,7 +2813,7 @@ msgid "Unsaved"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/import_editor.ex:871 #: lib/bds/desktop/shell_live/import_editor.ex:871
#: lib/bds/desktop/shell_live/post_editor/post_metadata.ex:166 #: lib/bds/ui/post_editor/metadata.ex:166
#: lib/bds/ui/sidebar.ex:1108 #: lib/bds/ui/sidebar.ex:1108
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Untitled" msgid "Untitled"
@@ -2805,7 +2838,8 @@ msgstr ""
msgid "Updated URLs" msgid "Updated URLs"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:228 #: lib/bds/desktop/menu_bar.ex:259
#: lib/bds/tui.ex:985
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Upload Site" msgid "Upload Site"
msgstr "" msgstr ""
@@ -2832,22 +2866,24 @@ msgstr ""
msgid "Validate" msgid "Validate"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:227 #: lib/bds/desktop/menu_bar.ex:258
#: lib/bds/tui.ex:963
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Validate Site" msgid "Validate Site"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:222 #: lib/bds/desktop/menu_bar.ex:253
#: lib/bds/tui.ex:976
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Validate Translations" msgid "Validate Translations"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:180 #: lib/bds/desktop/menu_bar.ex:209
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "View" msgid "View"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:235 #: lib/bds/desktop/menu_bar.ex:266
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "View on GitHub" msgid "View on GitHub"
msgstr "" msgstr ""
@@ -2891,12 +2927,12 @@ msgstr ""
msgid "You" msgid "You"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:211 #: lib/bds/desktop/menu_bar.ex:242
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Zoom In" msgid "Zoom In"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:212 #: lib/bds/desktop/menu_bar.ex:243
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Zoom Out" msgid "Zoom Out"
msgstr "" msgstr ""
@@ -3218,12 +3254,12 @@ msgstr ""
msgid "Comparing database and filesystem metadata" msgid "Comparing database and filesystem metadata"
msgstr "Comparing database and filesystem metadata" msgstr "Comparing database and filesystem metadata"
#: lib/bds/desktop/shell_live.ex:674 #: lib/bds/desktop/shell_live.ex:684
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Added %{count} images to post" msgid "Added %{count} images to post"
msgstr "Added %{count} images to post" msgstr "Added %{count} images to post"
#: lib/bds/desktop/shell_live.ex:642 #: lib/bds/desktop/shell_live.ex:652
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Added %{title}" msgid "Added %{title}"
msgstr "Added %{title}" msgstr "Added %{title}"
@@ -3243,18 +3279,18 @@ msgstr ""
msgid "Image Import Concurrency" msgid "Image Import Concurrency"
msgstr "Image Import Concurrency" msgstr "Image Import Concurrency"
#: lib/bds/desktop/shell_live.ex:447 #: lib/bds/desktop/shell_live.ex:444
#: lib/bds/desktop/shell_live.ex:460 #: lib/bds/desktop/shell_live.ex:457
#: lib/bds/desktop/shell_live.ex:641 #: lib/bds/desktop/shell_live.ex:651
#: lib/bds/desktop/shell_live.ex:673 #: lib/bds/desktop/shell_live.ex:683
#: lib/bds/desktop/shell_live.ex:684 #: lib/bds/desktop/shell_live.ex:694
#: lib/bds/desktop/shell_live.ex:695 #: lib/bds/desktop/shell_live.ex:705
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:420 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:420
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Add Gallery Images" msgid "Add Gallery Images"
msgstr "Add Gallery Images" msgstr "Add Gallery Images"
#: lib/bds/desktop/shell_live.ex:696 #: lib/bds/desktop/shell_live.ex:706
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed to process %{path}: %{reason}" msgid "Failed to process %{path}: %{reason}"
msgstr "Failed to process %{path}: %{reason}" msgstr "Failed to process %{path}: %{reason}"
@@ -3445,12 +3481,12 @@ msgstr ""
msgid "untracked" msgid "untracked"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:792 #: lib/bds/desktop/shell_live.ex:812
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Blogmark" msgid "Blogmark"
msgstr "Blogmark" msgstr "Blogmark"
#: lib/bds/desktop/shell_live.ex:835 #: lib/bds/desktop/shell_live.ex:855
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open a project before importing a blogmark." msgid "Open a project before importing a blogmark."
msgstr "" msgstr ""
@@ -3491,7 +3527,7 @@ msgstr ""
msgid "Failed to copy bookmarklet to clipboard" msgid "Failed to copy bookmarklet to clipboard"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:804 #: lib/bds/desktop/shell_live.ex:824
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "The project this blogmark targets does not exist here." msgid "The project this blogmark targets does not exist here."
msgstr "" msgstr ""
@@ -3501,7 +3537,8 @@ msgstr ""
msgid "Suggested tags" msgid "Suggested tags"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:226 #: lib/bds/desktop/menu_bar.ex:257
#: lib/bds/tui.ex:964
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Force Render Site" msgid "Force Render Site"
msgstr "" msgstr ""
@@ -3516,22 +3553,22 @@ msgstr ""
msgid "Rebuild Post Links" msgid "Rebuild Post Links"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:231 #: lib/bds/desktop/menu_bar.ex:262
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Bring All to Front" msgid "Bring All to Front"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:229 #: lib/bds/desktop/menu_bar.ex:260
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Minimize" msgid "Minimize"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:182 #: lib/bds/desktop/menu_bar.ex:211
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Window" msgid "Window"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:230 #: lib/bds/desktop/menu_bar.ex:261
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Zoom" msgid "Zoom"
msgstr "" msgstr ""
@@ -3578,12 +3615,12 @@ msgstr ""
msgid "Could not load models" msgid "Could not load models"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:187 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:190
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Could not save AI settings" msgid "Could not save AI settings"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:182 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:184
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Loaded %{count} models." msgid "Loaded %{count} models."
msgstr "" msgstr ""
@@ -3593,7 +3630,209 @@ msgstr ""
msgid "OK" msgid "OK"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:173 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:174
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "The endpoint returned no models. You can still type a model name manually." msgid "The endpoint returned no models. You can still type a model name manually."
msgstr "" msgstr ""
#: lib/bds/tui.ex:923
#, elixir-autogen, elixir-format
msgid "AI is unavailable in airplane mode without a local endpoint."
msgstr ""
#: lib/bds/tui.ex:1049
#, elixir-autogen, elixir-format, fuzzy
msgid "Could not load this file."
msgstr ""
#: lib/bds/tui.ex:158
#, elixir-autogen, elixir-format
msgid "Editing this item is not available in the terminal UI yet."
msgstr ""
#: lib/bds/tui.ex:376
#, elixir-autogen, elixir-format
msgid "No suggestions returned."
msgstr ""
#: lib/bds/tui.ex:1046
#, elixir-autogen, elixir-format
msgid "Only images can be previewed."
msgstr ""
#: lib/bds/tui.ex:846
#, elixir-autogen, elixir-format
msgid "Post not found."
msgstr ""
#: lib/bds/tui.ex:505
#, elixir-autogen, elixir-format
msgid "Press enter to preview %{name}."
msgstr ""
#: lib/bds/tui.ex:893
#, elixir-autogen, elixir-format, fuzzy
msgid "Published."
msgstr ""
#: lib/bds/tui.ex:909
#, elixir-autogen, elixir-format
msgid "Save before switching languages."
msgstr ""
#: lib/bds/tui.ex:894
#, elixir-autogen, elixir-format, fuzzy
msgid "Saved."
msgstr ""
#: lib/bds/tui.ex:508
#, elixir-autogen, elixir-format
msgid "Select an entry and press enter to open it."
msgstr ""
#: lib/bds/tui.ex:373
#, elixir-autogen, elixir-format
msgid "Suggestions applied."
msgstr ""
#: lib/bds/tui.ex:520
#, elixir-autogen, elixir-format
msgid "Title (ctrl+t to edit)"
msgstr ""
#: lib/bds/tui.ex:519
#, elixir-autogen, elixir-format
msgid "Title (editing — enter to confirm)"
msgstr ""
#: lib/bds/tui.ex:567
#, elixir-autogen, elixir-format, fuzzy
msgid "Working…"
msgstr ""
#: lib/bds/tui.ex:589
#, elixir-autogen, elixir-format
msgid "esc to close"
msgstr ""
#: lib/bds/desktop/menu_bar.ex:83
#: lib/bds/desktop/shell_live.ex:1110
#, elixir-autogen, elixir-format
msgid "Connect to Server"
msgstr ""
#: lib/bds/desktop/menu_bar.ex:218
#, elixir-autogen, elixir-format
msgid "Connect to Server…"
msgstr ""
#: lib/bds/desktop/menu_bar.ex:84
#, elixir-autogen, elixir-format
msgid "Connection failed: %{reason}"
msgstr ""
#: lib/bds/desktop/menu_bar.ex:219
#, elixir-autogen, elixir-format
msgid "Disconnect from Server"
msgstr ""
#: lib/bds/desktop/menu_bar.ex:72
#: lib/bds/desktop/shell_live.ex:1097
#, elixir-autogen, elixir-format
msgid "Server address (user@host or user@host:port), public-key auth:"
msgstr ""
#: lib/bds/desktop/remote_connection.ex:82
#, elixir-autogen, elixir-format
msgid "Use the form user@host or user@host:port."
msgstr ""
#: lib/bds/tui.ex:539
#, elixir-autogen, elixir-format
msgid "Preview (ctrl+e to edit)"
msgstr ""
#: lib/bds/tui.ex:748
#, elixir-autogen, elixir-format, fuzzy
msgid "ctrl+s save · ctrl+p publish · ctrl+e preview · ctrl+t title · ctrl+l language · ctrl+g AI · esc back"
msgstr ""
#: lib/bds/tui.ex:403
#, elixir-autogen, elixir-format
msgid "All tasks finished."
msgstr ""
#: lib/bds/tui.ex:615
#, elixir-autogen, elixir-format
msgid "Commands — esc to close"
msgstr ""
#: lib/bds/tui.ex:247
#, elixir-autogen, elixir-format, fuzzy
msgid "Unknown command."
msgstr ""
#: lib/bds/tui.ex:605
#, elixir-autogen, elixir-format
msgid "[report/apply in GUI]"
msgstr ""
#: lib/bds/tui.ex:670
#, elixir-autogen, elixir-format
msgid "%{diffs} differences · %{orphans} orphan files"
msgstr ""
#: lib/bds/tui.ex:702
#, elixir-autogen, elixir-format
msgid "Expected %{expected} · Existing %{existing}"
msgstr ""
#: lib/bds/tui.ex:719
#, elixir-autogen, elixir-format
msgid "Extra files (will be removed):"
msgstr ""
#: lib/bds/tui.ex:715
#, elixir-autogen, elixir-format
msgid "Missing pages (will be rendered):"
msgstr ""
#: lib/bds/tui.ex:226
#, elixir-autogen, elixir-format, fuzzy
msgid "Nothing to apply."
msgstr ""
#: lib/bds/tui.ex:193
#, elixir-autogen, elixir-format, fuzzy
msgid "Nothing to repair."
msgstr ""
#: lib/bds/tui.ex:693
#, elixir-autogen, elixir-format
msgid "Orphan files (not in database):"
msgstr ""
#: lib/bds/tui.ex:709
#, elixir-autogen, elixir-format
msgid "Sitemap changed."
msgstr ""
#: lib/bds/tui.ex:723
#, elixir-autogen, elixir-format
msgid "Updated posts (will be re-rendered):"
msgstr ""
#: lib/bds/tui.ex:660
#, elixir-autogen, elixir-format
msgid "enter apply changes · esc close"
msgstr ""
#: lib/bds/tui.ex:741
#, elixir-autogen, elixir-format, fuzzy
msgid "enter open · n new post · 1-5 views · : commands (:? help) · r refresh · ctrl+q quit"
msgstr ""
#: lib/bds/tui.ex:655
#, elixir-autogen, elixir-format
msgid "enter repair all from files · esc close"
msgstr ""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ msgstr ""
msgid "%{count} mapped" msgid "%{count} mapped"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:171 #: lib/bds/desktop/shell_data.ex:173
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "%{count} post" msgid "%{count} post"
msgid_plural "%{count} posts" msgid_plural "%{count} posts"
@@ -84,8 +84,8 @@ msgid "AI Assistant"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:93 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:93
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:190 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:193
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:208 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:211
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "AI Settings" msgid "AI Settings"
msgstr "" msgstr ""
@@ -94,6 +94,11 @@ msgstr ""
#: lib/bds/desktop/shell_live/overlay_manager.ex:72 #: lib/bds/desktop/shell_live/overlay_manager.ex:72
#: lib/bds/desktop/shell_live/post_editor.ex:920 #: lib/bds/desktop/shell_live/post_editor.ex:920
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:43 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:43
#: lib/bds/tui.ex:373
#: lib/bds/tui.ex:376
#: lib/bds/tui.ex:379
#: lib/bds/tui.ex:387
#: lib/bds/tui.ex:922
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "AI Suggestions" msgid "AI Suggestions"
msgstr "" msgstr ""
@@ -118,12 +123,12 @@ msgstr ""
msgid "API" msgid "API"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:234 #: lib/bds/desktop/menu_bar.ex:265
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "API Documentation" msgid "API Documentation"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:232 #: lib/bds/desktop/menu_bar.ex:263
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "About" msgid "About"
msgstr "" msgstr ""
@@ -314,7 +319,7 @@ msgstr ""
msgid "Validation apply prepared" msgid "Validation apply prepared"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:164 #: lib/bds/desktop/shell_data.ex:166
#: lib/bds/ui/sidebar.ex:331 #: lib/bds/ui/sidebar.ex:331
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Archived" msgid "Archived"
@@ -349,7 +354,7 @@ msgstr ""
msgid "Auto" msgid "Auto"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:448 #: lib/bds/desktop/shell_live.ex:445
#: lib/bds/desktop/shell_live/chat_editor.ex:234 #: lib/bds/desktop/shell_live/chat_editor.ex:234
#: lib/bds/desktop/shell_live/media_editor.ex:171 #: lib/bds/desktop/shell_live/media_editor.ex:171
#: lib/bds/desktop/shell_live/media_editor.ex:364 #: lib/bds/desktop/shell_live/media_editor.ex:364
@@ -380,7 +385,7 @@ msgstr ""
msgid "Backlinks" msgid "Backlinks"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:181 #: lib/bds/desktop/menu_bar.ex:210
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Blog" msgid "Blog"
msgstr "" msgstr ""
@@ -499,7 +504,7 @@ msgstr ""
msgid "Category name is required" msgid "Category name is required"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:771 #: lib/bds/desktop/shell_live.ex:791
#: lib/bds/desktop/shell_live/chat_editor.ex:87 #: lib/bds/desktop/shell_live/chat_editor.ex:87
#: lib/bds/desktop/shell_live/chat_editor.ex:233 #: lib/bds/desktop/shell_live/chat_editor.ex:233
#: lib/bds/desktop/shell_live/chat_editor.ex:323 #: lib/bds/desktop/shell_live/chat_editor.ex:323
@@ -557,7 +562,7 @@ msgstr ""
msgid "Click to %{action} mapping" msgid "Click to %{action} mapping"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:190 #: lib/bds/desktop/menu_bar.ex:221
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Close Tab" msgid "Close Tab"
msgstr "" msgstr ""
@@ -580,6 +585,7 @@ msgid "Collapse unchanged diff hunks"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/overlay_manager.ex:422 #: lib/bds/desktop/shell_live/overlay_manager.ex:422
#: lib/bds/tui.ex:1017
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Command completed" msgid "Command completed"
msgstr "" msgstr ""
@@ -597,6 +603,7 @@ msgstr ""
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:375 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:375
#: lib/bds/desktop/shell_live/script_editor_html/script_editor.html.heex:34 #: lib/bds/desktop/shell_live/script_editor_html/script_editor.html.heex:34
#: lib/bds/desktop/shell_live/template_editor_html/template_editor.html.heex:32 #: lib/bds/desktop/shell_live/template_editor_html/template_editor.html.heex:32
#: lib/bds/tui.ex:550
#: lib/bds/ui/sidebar.ex:764 #: lib/bds/ui/sidebar.ex:764
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Content" msgid "Content"
@@ -607,7 +614,7 @@ msgstr ""
msgid "Content Categories" msgid "Content Categories"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:195 #: lib/bds/desktop/menu_bar.ex:226
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Copy" msgid "Copy"
msgstr "" msgstr ""
@@ -660,7 +667,7 @@ msgstr ""
msgid "Created" msgid "Created"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:194 #: lib/bds/desktop/menu_bar.ex:225
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cut" msgid "Cut"
msgstr "" msgstr ""
@@ -726,7 +733,7 @@ msgstr ""
msgid "Default editing mode and diff presentation" msgid "Default editing mode and diff presentation"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:197 #: lib/bds/desktop/menu_bar.ex:228
#: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:98 #: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:98
#: lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex:166 #: lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex:166
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:116 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:116
@@ -766,7 +773,7 @@ msgstr ""
msgid "Delete conversation" msgid "Delete conversation"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:65 #: lib/bds/ui/post_editor/persistence.ex:69
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete this unpublished draft" msgid "Delete this unpublished draft"
msgstr "" msgstr ""
@@ -823,17 +830,17 @@ msgstr ""
msgid "Disable reasoning output for the online chat model" msgid "Disable reasoning output for the online chat model"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:57 #: lib/bds/ui/post_editor/persistence.ex:61
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Discard Changes" msgid "Discard Changes"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:58 #: lib/bds/ui/post_editor/persistence.ex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Discard Draft" msgid "Discard Draft"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:64 #: lib/bds/ui/post_editor/persistence.ex:68
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Discard changes and restore the published version" msgid "Discard changes and restore the published version"
msgstr "" msgstr ""
@@ -869,14 +876,14 @@ msgstr ""
msgid "Do Not Translate" msgid "Do Not Translate"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:233 #: lib/bds/desktop/menu_bar.ex:264
#: lib/bds/help_docs.ex:14 #: lib/bds/help_docs.ex:14
#: lib/bds/ui/registry.ex:118 #: lib/bds/ui/registry.ex:118
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Documentation" msgid "Documentation"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:162 #: lib/bds/desktop/shell_data.ex:164
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Draft" msgid "Draft"
msgstr "" msgstr ""
@@ -902,12 +909,12 @@ msgstr ""
msgid "ETA: %{value}" msgid "ETA: %{value}"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:179 #: lib/bds/desktop/menu_bar.ex:208
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:216 #: lib/bds/desktop/menu_bar.ex:247
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit Menu" msgid "Edit Menu"
msgstr "" msgstr ""
@@ -1013,7 +1020,7 @@ msgstr ""
msgid "Extra URLs" msgid "Extra URLs"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:178 #: lib/bds/desktop/menu_bar.ex:207
#: lib/bds/desktop/shell_live/import_editor.ex:882 #: lib/bds/desktop/shell_live/import_editor.ex:882
#: lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex:157 #: lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex:157
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -1035,17 +1042,19 @@ msgstr ""
msgid "Filename" msgid "Filename"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:223 #: lib/bds/desktop/menu_bar.ex:254
#: lib/bds/tui.ex:981
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Fill Missing Translations" msgid "Fill Missing Translations"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:199 #: lib/bds/desktop/menu_bar.ex:230
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Find" msgid "Find"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:224 #: lib/bds/desktop/menu_bar.ex:255
#: lib/bds/tui.ex:984
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Find Duplicate Posts" msgid "Find Duplicate Posts"
msgstr "" msgstr ""
@@ -1060,7 +1069,7 @@ msgstr ""
msgid "Find Duplicates" msgid "Find Duplicates"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:209 #: lib/bds/desktop/menu_bar.ex:240
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Force Reload" msgid "Force Reload"
msgstr "" msgstr ""
@@ -1071,12 +1080,13 @@ msgstr ""
msgid "Gallery" msgid "Gallery"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:225 #: lib/bds/desktop/menu_bar.ex:256
#: lib/bds/tui.ex:965
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Generate Site" msgid "Generate Site"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:772 #: lib/bds/desktop/shell_live.ex:792
#: lib/bds/desktop/shell_live/socket_state.ex:109 #: lib/bds/desktop/shell_live/socket_state.ex:109
#: lib/bds/ui/sidebar.ex:789 #: lib/bds/ui/sidebar.ex:789
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -1089,14 +1099,14 @@ msgstr ""
msgid "Git Diff" msgid "Git Diff"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:226 #: lib/bds/desktop/shell_data.ex:228
#: lib/bds/desktop/shell_live.ex:768 #: lib/bds/desktop/shell_live.ex:788
#: lib/bds/desktop/shell_live/panel_renderer.ex:171 #: lib/bds/desktop/shell_live/panel_renderer.ex:171
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Git Log" msgid "Git Log"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:183 #: lib/bds/desktop/menu_bar.ex:212
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Help" msgid "Help"
msgstr "" msgstr ""
@@ -1116,7 +1126,7 @@ msgstr ""
msgid "Host" msgid "Host"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:98 #: lib/bds/desktop/shell_data.ex:100
#: lib/bds/desktop/shell_live/index.html.heex:657 #: lib/bds/desktop/shell_live/index.html.heex:657
#: lib/bds/desktop/shell_live/media_editor.ex:731 #: lib/bds/desktop/shell_live/media_editor.ex:731
#: lib/bds/desktop/shell_live/post_editor.ex:1038 #: lib/bds/desktop/shell_live/post_editor.ex:1038
@@ -1183,7 +1193,7 @@ msgstr ""
msgid "Import (new slug)" msgid "Import (new slug)"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:186 #: lib/bds/desktop/menu_bar.ex:215
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import Media" msgid "Import Media"
msgstr "" msgstr ""
@@ -1213,9 +1223,9 @@ msgstr ""
msgid "Import failed: %{error}" msgid "Import failed: %{error}"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:598 #: lib/bds/desktop/shell_live.ex:608
#: lib/bds/desktop/shell_live.ex:884 #: lib/bds/desktop/shell_live.ex:904
#: lib/bds/desktop/shell_live.ex:890 #: lib/bds/desktop/shell_live.ex:910
#: lib/bds/desktop/shell_live/sidebar_create.ex:47 #: lib/bds/desktop/shell_live/sidebar_create.ex:47
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import media" msgid "Import media"
@@ -1350,7 +1360,7 @@ msgstr ""
msgid "Macros (%{count})" msgid "Macros (%{count})"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:101 #: lib/bds/desktop/shell_data.ex:103
#: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:55 #: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Main Language" msgid "Main Language"
@@ -1385,13 +1395,16 @@ msgstr ""
msgid "Max Posts Per Page" msgid "Max Posts Per Page"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:203 #: lib/bds/desktop/menu_bar.ex:234
#: lib/bds/desktop/shell_live/media_editor.ex:509 #: lib/bds/desktop/shell_live/media_editor.ex:509
#: lib/bds/desktop/shell_live/media_editor.ex:513 #: lib/bds/desktop/shell_live/media_editor.ex:513
#: lib/bds/desktop/shell_live/misc_editor.ex:735 #: lib/bds/desktop/shell_live/misc_editor.ex:735
#: lib/bds/desktop/shell_live/misc_editor.ex:762 #: lib/bds/desktop/shell_live/misc_editor.ex:762
#: lib/bds/desktop/shell_live/sidebar_components.ex:654 #: lib/bds/desktop/shell_live/sidebar_components.ex:654
#: lib/bds/desktop/shell_live/sidebar_delete.ex:175 #: lib/bds/desktop/shell_live/sidebar_delete.ex:175
#: lib/bds/tui.ex:836
#: lib/bds/tui.ex:1046
#: lib/bds/tui.ex:1049
#: lib/bds/ui/registry.ex:30 #: lib/bds/ui/registry.ex:30
#: lib/bds/ui/registry.ex:100 #: lib/bds/ui/registry.ex:100
#: lib/bds/ui/sidebar.ex:558 #: lib/bds/ui/sidebar.ex:558
@@ -1430,10 +1443,15 @@ msgstr ""
msgid "Metadata" msgid "Metadata"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:220 #: lib/bds/desktop/menu_bar.ex:251
#: lib/bds/desktop/shell_live/misc_editor.ex:214 #: lib/bds/desktop/shell_live/misc_editor.ex:214
#: lib/bds/desktop/shell_live/misc_editor.ex:226 #: lib/bds/desktop/shell_live/misc_editor.ex:226
#: lib/bds/desktop/shell_live/misc_editor.ex:448 #: lib/bds/desktop/shell_live/misc_editor.ex:448
#: lib/bds/tui.ex:193
#: lib/bds/tui.ex:199
#: lib/bds/tui.ex:210
#: lib/bds/tui.ex:654
#: lib/bds/tui.ex:962
#: lib/bds/ui/registry.ex:111 #: lib/bds/ui/registry.ex:111
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Metadata Diff" msgid "Metadata Diff"
@@ -1444,7 +1462,7 @@ msgstr ""
msgid "Missing URLs" msgid "Missing URLs"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:100 #: lib/bds/desktop/shell_data.ex:102
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Mode" msgid "Mode"
msgstr "" msgstr ""
@@ -1477,8 +1495,9 @@ msgstr ""
msgid "New Page" msgid "New Page"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:185 #: lib/bds/desktop/menu_bar.ex:214
#: lib/bds/desktop/shell_live/sidebar_create.ex:41 #: lib/bds/desktop/shell_live/sidebar_create.ex:41
#: lib/bds/tui.ex:150
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "New Post" msgid "New Post"
msgstr "" msgstr ""
@@ -1657,7 +1676,7 @@ msgstr ""
msgid "Nothing to Import" msgid "Nothing to Import"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:100 #: lib/bds/desktop/shell_data.ex:102
#: lib/bds/desktop/shell_live/index.html.heex:503 #: lib/bds/desktop/shell_live/index.html.heex:503
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Offline" msgid "Offline"
@@ -1749,7 +1768,7 @@ msgstr ""
msgid "Open" msgid "Open"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:189 #: lib/bds/desktop/menu_bar.ex:220
#: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:382 #: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:382
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open Data Folder" msgid "Open Data Folder"
@@ -1766,7 +1785,8 @@ msgstr ""
msgid "Open Settings" msgid "Open Settings"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:188 #: lib/bds/desktop/menu_bar.ex:217
#: lib/bds/tui.ex:986
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open in Browser" msgid "Open in Browser"
msgstr "" msgstr ""
@@ -1797,7 +1817,7 @@ msgstr ""
msgid "Other (%{count})" msgid "Other (%{count})"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:767 #: lib/bds/desktop/shell_live.ex:787
#: lib/bds/desktop/shell_live/panel_renderer.ex:83 #: lib/bds/desktop/shell_live/panel_renderer.ex:83
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Output" msgid "Output"
@@ -1845,7 +1865,7 @@ msgstr ""
msgid "Parsing WXR file..." msgid "Parsing WXR file..."
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:196 #: lib/bds/desktop/menu_bar.ex:227
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paste" msgid "Paste"
msgstr "" msgstr ""
@@ -1879,7 +1899,7 @@ msgstr ""
msgid "Post" msgid "Post"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:229 #: lib/bds/desktop/shell_data.ex:231
#: lib/bds/desktop/shell_live/panel_renderer.ex:118 #: lib/bds/desktop/shell_live/panel_renderer.ex:118
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:310 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:310
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -1911,9 +1931,11 @@ msgstr ""
msgid "Post saved" msgid "Post saved"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:202 #: lib/bds/desktop/menu_bar.ex:233
#: lib/bds/desktop/shell_live/misc_editor.ex:664 #: lib/bds/desktop/shell_live/misc_editor.ex:664
#: lib/bds/desktop/shell_live/misc_editor.ex:761 #: lib/bds/desktop/shell_live/misc_editor.ex:761
#: lib/bds/tui.ex:835
#: lib/bds/tui.ex:846
#: lib/bds/ui/registry.ex:14 #: lib/bds/ui/registry.ex:14
#: lib/bds/ui/sidebar.ex:271 #: lib/bds/ui/sidebar.ex:271
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -1925,7 +1947,7 @@ msgstr ""
msgid "Posts (%{count})" msgid "Posts (%{count})"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:201 #: lib/bds/desktop/menu_bar.ex:232
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Preferences" msgid "Preferences"
msgstr "" msgstr ""
@@ -1941,7 +1963,7 @@ msgstr ""
msgid "Preview Mode" msgid "Preview Mode"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:215 #: lib/bds/desktop/menu_bar.ex:246
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Preview Post" msgid "Preview Post"
msgstr "" msgstr ""
@@ -1993,12 +2015,12 @@ msgstr ""
msgid "Publish" msgid "Publish"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:214 #: lib/bds/desktop/menu_bar.ex:245
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Publish Selected" msgid "Publish Selected"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:163 #: lib/bds/desktop/shell_data.ex:165
#: lib/bds/desktop/shell_live/post_editor.ex:1036 #: lib/bds/desktop/shell_live/post_editor.ex:1036
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:472 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:472
#: lib/bds/ui/sidebar.ex:324 #: lib/bds/ui/sidebar.ex:324
@@ -2026,7 +2048,7 @@ msgstr ""
msgid "Quick Actions" msgid "Quick Actions"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:191 #: lib/bds/desktop/menu_bar.ex:222
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Quit" msgid "Quit"
msgstr "" msgstr ""
@@ -2036,13 +2058,15 @@ msgstr ""
msgid "Ready to import:" msgid "Ready to import:"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:217 #: lib/bds/desktop/menu_bar.ex:248
#: lib/bds/tui.ex:966
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Rebuild Database" msgid "Rebuild Database"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:219 #: lib/bds/desktop/menu_bar.ex:250
#: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:381 #: lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex:381
#: lib/bds/tui.ex:970
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Rebuild Embedding Index" msgid "Rebuild Embedding Index"
msgstr "" msgstr ""
@@ -2072,7 +2096,7 @@ msgstr ""
msgid "Rebuild filesystem-backed records and thumbnails" msgid "Rebuild filesystem-backed records and thumbnails"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:193 #: lib/bds/desktop/menu_bar.ex:224
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Redo" msgid "Redo"
msgstr "" msgstr ""
@@ -2099,7 +2123,8 @@ msgstr ""
msgid "Refresh Translation" msgid "Refresh Translation"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:221 #: lib/bds/desktop/menu_bar.ex:252
#: lib/bds/tui.ex:973
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Regenerate Calendar" msgid "Regenerate Calendar"
msgstr "" msgstr ""
@@ -2109,12 +2134,13 @@ msgstr ""
msgid "Regenerate Missing Thumbnails" msgid "Regenerate Missing Thumbnails"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:218 #: lib/bds/desktop/menu_bar.ex:249
#: lib/bds/tui.ex:967
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reindex Text" msgid "Reindex Text"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:208 #: lib/bds/desktop/menu_bar.ex:239
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reload" msgid "Reload"
msgstr "" msgstr ""
@@ -2145,7 +2171,7 @@ msgstr ""
msgid "Render in Lists" msgid "Render in Lists"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:200 #: lib/bds/desktop/menu_bar.ex:231
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Replace" msgid "Replace"
msgstr "" msgstr ""
@@ -2162,12 +2188,12 @@ msgstr ""
msgid "Replace Media File" msgid "Replace Media File"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:236 #: lib/bds/desktop/menu_bar.ex:267
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Report Issue" msgid "Report Issue"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:210 #: lib/bds/desktop/menu_bar.ex:241
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reset Zoom" msgid "Reset Zoom"
msgstr "" msgstr ""
@@ -2222,7 +2248,7 @@ msgstr ""
msgid "SSH Mode" msgid "SSH Mode"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:187 #: lib/bds/desktop/menu_bar.ex:216
#: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:89 #: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:89
#: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:299 #: lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex:299
#: lib/bds/desktop/shell_live/script_editor_html/script_editor.html.heex:13 #: lib/bds/desktop/shell_live/script_editor_html/script_editor.html.heex:13
@@ -2295,6 +2321,7 @@ msgstr ""
#: lib/bds/desktop/shell_live/script_editor.ex:216 #: lib/bds/desktop/shell_live/script_editor.ex:216
#: lib/bds/desktop/shell_live/script_editor.ex:219 #: lib/bds/desktop/shell_live/script_editor.ex:219
#: lib/bds/desktop/shell_live/script_editor.ex:234 #: lib/bds/desktop/shell_live/script_editor.ex:234
#: lib/bds/tui.ex:838
#: lib/bds/ui/registry.ex:38 #: lib/bds/ui/registry.ex:38
#: lib/bds/ui/sidebar.ex:63 #: lib/bds/ui/sidebar.ex:63
#: lib/bds/ui/sidebar.ex:115 #: lib/bds/ui/sidebar.ex:115
@@ -2340,7 +2367,7 @@ msgstr ""
msgid "Select & Analyze" msgid "Select & Analyze"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:198 #: lib/bds/desktop/menu_bar.ex:229
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
@@ -2422,6 +2449,9 @@ msgid "Site"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/misc_editor.ex:412 #: lib/bds/desktop/shell_live/misc_editor.ex:412
#: lib/bds/tui.ex:226
#: lib/bds/tui.ex:228
#: lib/bds/tui.ex:659
#: lib/bds/ui/registry.ex:125 #: lib/bds/ui/registry.ex:125
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Site Validation" msgid "Site Validation"
@@ -2469,7 +2499,7 @@ msgstr ""
msgid "Starting..." msgid "Starting..."
msgstr "" msgstr ""
#: lib/bds/desktop/shell_data.ex:97 #: lib/bds/desktop/shell_data.ex:99
#: lib/bds/desktop/shell_live/import_editor.ex:1182 #: lib/bds/desktop/shell_live/import_editor.ex:1182
#: lib/bds/desktop/shell_live/import_editor.ex:1233 #: lib/bds/desktop/shell_live/import_editor.ex:1233
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@@ -2542,6 +2572,7 @@ msgstr ""
#: lib/bds/desktop/shell_live/tags_editor.ex:222 #: lib/bds/desktop/shell_live/tags_editor.ex:222
#: lib/bds/desktop/shell_live/tags_editor.ex:253 #: lib/bds/desktop/shell_live/tags_editor.ex:253
#: lib/bds/desktop/shell_live/tags_editor_html/tags_editor.html.heex:11 #: lib/bds/desktop/shell_live/tags_editor_html/tags_editor.html.heex:11
#: lib/bds/tui.ex:839
#: lib/bds/ui/registry.ex:54 #: lib/bds/ui/registry.ex:54
#: lib/bds/ui/registry.ex:103 #: lib/bds/ui/registry.ex:103
#: lib/bds/ui/sidebar.ex:289 #: lib/bds/ui/sidebar.ex:289
@@ -2551,8 +2582,9 @@ msgstr ""
msgid "Tags" msgid "Tags"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:766 #: lib/bds/desktop/shell_live.ex:786
#: lib/bds/desktop/shell_live/panel_renderer.ex:54 #: lib/bds/desktop/shell_live/panel_renderer.ex:54
#: lib/bds/tui.ex:403
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Tasks" msgid "Tasks"
msgstr "" msgstr ""
@@ -2597,6 +2629,7 @@ msgstr ""
#: lib/bds/desktop/shell_live/template_editor.ex:171 #: lib/bds/desktop/shell_live/template_editor.ex:171
#: lib/bds/desktop/shell_live/template_editor.ex:176 #: lib/bds/desktop/shell_live/template_editor.ex:176
#: lib/bds/desktop/shell_live/template_editor.ex:191 #: lib/bds/desktop/shell_live/template_editor.ex:191
#: lib/bds/tui.ex:837
#: lib/bds/ui/registry.ex:46 #: lib/bds/ui/registry.ex:46
#: lib/bds/ui/sidebar.ex:71 #: lib/bds/ui/sidebar.ex:71
#: lib/bds/ui/sidebar.ex:122 #: lib/bds/ui/sidebar.ex:122
@@ -2646,12 +2679,12 @@ msgstr ""
msgid "Title" msgid "Title"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:206 #: lib/bds/desktop/menu_bar.ex:237
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle Assistant Sidebar" msgid "Toggle Assistant Sidebar"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:207 #: lib/bds/desktop/menu_bar.ex:238
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle Dev Tools" msgid "Toggle Dev Tools"
msgstr "" msgstr ""
@@ -2662,17 +2695,17 @@ msgstr ""
msgid "Toggle Filters" msgid "Toggle Filters"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:213 #: lib/bds/desktop/menu_bar.ex:244
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle Full Screen" msgid "Toggle Full Screen"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:205 #: lib/bds/desktop/menu_bar.ex:236
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle Panel" msgid "Toggle Panel"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:204 #: lib/bds/desktop/menu_bar.ex:235
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Toggle Sidebar" msgid "Toggle Sidebar"
msgstr "" msgstr ""
@@ -2768,7 +2801,7 @@ msgstr ""
msgid "URL" msgid "URL"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:192 #: lib/bds/desktop/menu_bar.ex:223
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Undo" msgid "Undo"
msgstr "" msgstr ""
@@ -2793,7 +2826,7 @@ msgid "Unsaved"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/import_editor.ex:871 #: lib/bds/desktop/shell_live/import_editor.ex:871
#: lib/bds/desktop/shell_live/post_editor/post_metadata.ex:166 #: lib/bds/ui/post_editor/metadata.ex:166
#: lib/bds/ui/sidebar.ex:1108 #: lib/bds/ui/sidebar.ex:1108
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Untitled" msgid "Untitled"
@@ -2818,7 +2851,8 @@ msgstr ""
msgid "Updated URLs" msgid "Updated URLs"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:228 #: lib/bds/desktop/menu_bar.ex:259
#: lib/bds/tui.ex:985
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Upload Site" msgid "Upload Site"
msgstr "" msgstr ""
@@ -2845,22 +2879,24 @@ msgstr ""
msgid "Validate" msgid "Validate"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:227 #: lib/bds/desktop/menu_bar.ex:258
#: lib/bds/tui.ex:963
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Validate Site" msgid "Validate Site"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:222 #: lib/bds/desktop/menu_bar.ex:253
#: lib/bds/tui.ex:976
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Validate Translations" msgid "Validate Translations"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:180 #: lib/bds/desktop/menu_bar.ex:209
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "View" msgid "View"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:235 #: lib/bds/desktop/menu_bar.ex:266
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "View on GitHub" msgid "View on GitHub"
msgstr "" msgstr ""
@@ -2904,12 +2940,12 @@ msgstr ""
msgid "You" msgid "You"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:211 #: lib/bds/desktop/menu_bar.ex:242
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Zoom In" msgid "Zoom In"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:212 #: lib/bds/desktop/menu_bar.ex:243
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Zoom Out" msgid "Zoom Out"
msgstr "" msgstr ""
@@ -3231,12 +3267,12 @@ msgstr ""
msgid "Comparing database and filesystem metadata" msgid "Comparing database and filesystem metadata"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:674 #: lib/bds/desktop/shell_live.ex:684
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Added %{count} images to post" msgid "Added %{count} images to post"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:642 #: lib/bds/desktop/shell_live.ex:652
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Added %{title}" msgid "Added %{title}"
msgstr "" msgstr ""
@@ -3256,18 +3292,18 @@ msgstr ""
msgid "Image Import Concurrency" msgid "Image Import Concurrency"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:447 #: lib/bds/desktop/shell_live.ex:444
#: lib/bds/desktop/shell_live.ex:460 #: lib/bds/desktop/shell_live.ex:457
#: lib/bds/desktop/shell_live.ex:641 #: lib/bds/desktop/shell_live.ex:651
#: lib/bds/desktop/shell_live.ex:673 #: lib/bds/desktop/shell_live.ex:683
#: lib/bds/desktop/shell_live.ex:684 #: lib/bds/desktop/shell_live.ex:694
#: lib/bds/desktop/shell_live.ex:695 #: lib/bds/desktop/shell_live.ex:705
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:420 #: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:420
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Add Gallery Images" msgid "Add Gallery Images"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:696 #: lib/bds/desktop/shell_live.ex:706
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed to process %{path}: %{reason}" msgid "Failed to process %{path}: %{reason}"
msgstr "" msgstr ""
@@ -3458,12 +3494,12 @@ msgstr ""
msgid "untracked" msgid "untracked"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:792 #: lib/bds/desktop/shell_live.ex:812
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Blogmark" msgid "Blogmark"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:835 #: lib/bds/desktop/shell_live.ex:855
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open a project before importing a blogmark." msgid "Open a project before importing a blogmark."
msgstr "" msgstr ""
@@ -3504,7 +3540,7 @@ msgstr ""
msgid "Failed to copy bookmarklet to clipboard" msgid "Failed to copy bookmarklet to clipboard"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live.ex:804 #: lib/bds/desktop/shell_live.ex:824
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "The project this blogmark targets does not exist here." msgid "The project this blogmark targets does not exist here."
msgstr "" msgstr ""
@@ -3514,7 +3550,8 @@ msgstr ""
msgid "Suggested tags" msgid "Suggested tags"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:226 #: lib/bds/desktop/menu_bar.ex:257
#: lib/bds/tui.ex:964
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Force Render Site" msgid "Force Render Site"
msgstr "" msgstr ""
@@ -3529,22 +3566,22 @@ msgstr ""
msgid "Rebuild Post Links" msgid "Rebuild Post Links"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:231 #: lib/bds/desktop/menu_bar.ex:262
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Bring All to Front" msgid "Bring All to Front"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:229 #: lib/bds/desktop/menu_bar.ex:260
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Minimize" msgid "Minimize"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:182 #: lib/bds/desktop/menu_bar.ex:211
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Window" msgid "Window"
msgstr "" msgstr ""
#: lib/bds/desktop/menu_bar.ex:230 #: lib/bds/desktop/menu_bar.ex:261
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Zoom" msgid "Zoom"
msgstr "" msgstr ""
@@ -3591,12 +3628,12 @@ msgstr ""
msgid "Could not load models" msgid "Could not load models"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:187 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:190
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Could not save AI settings" msgid "Could not save AI settings"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:182 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:184
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Loaded %{count} models." msgid "Loaded %{count} models."
msgstr "" msgstr ""
@@ -3606,7 +3643,209 @@ msgstr ""
msgid "OK" msgid "OK"
msgstr "" msgstr ""
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:173 #: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:174
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "The endpoint returned no models. You can still type a model name manually." msgid "The endpoint returned no models. You can still type a model name manually."
msgstr "" msgstr ""
#: lib/bds/tui.ex:923
#, elixir-autogen, elixir-format
msgid "AI is unavailable in airplane mode without a local endpoint."
msgstr ""
#: lib/bds/tui.ex:1049
#, elixir-autogen, elixir-format
msgid "Could not load this file."
msgstr ""
#: lib/bds/tui.ex:158
#, elixir-autogen, elixir-format
msgid "Editing this item is not available in the terminal UI yet."
msgstr ""
#: lib/bds/tui.ex:376
#, elixir-autogen, elixir-format
msgid "No suggestions returned."
msgstr ""
#: lib/bds/tui.ex:1046
#, elixir-autogen, elixir-format
msgid "Only images can be previewed."
msgstr ""
#: lib/bds/tui.ex:846
#, elixir-autogen, elixir-format
msgid "Post not found."
msgstr ""
#: lib/bds/tui.ex:505
#, elixir-autogen, elixir-format
msgid "Press enter to preview %{name}."
msgstr ""
#: lib/bds/tui.ex:893
#, elixir-autogen, elixir-format
msgid "Published."
msgstr ""
#: lib/bds/tui.ex:909
#, elixir-autogen, elixir-format
msgid "Save before switching languages."
msgstr ""
#: lib/bds/tui.ex:894
#, elixir-autogen, elixir-format
msgid "Saved."
msgstr ""
#: lib/bds/tui.ex:508
#, elixir-autogen, elixir-format
msgid "Select an entry and press enter to open it."
msgstr ""
#: lib/bds/tui.ex:373
#, elixir-autogen, elixir-format
msgid "Suggestions applied."
msgstr ""
#: lib/bds/tui.ex:520
#, elixir-autogen, elixir-format
msgid "Title (ctrl+t to edit)"
msgstr ""
#: lib/bds/tui.ex:519
#, elixir-autogen, elixir-format
msgid "Title (editing — enter to confirm)"
msgstr ""
#: lib/bds/tui.ex:567
#, elixir-autogen, elixir-format
msgid "Working…"
msgstr ""
#: lib/bds/tui.ex:589
#, elixir-autogen, elixir-format
msgid "esc to close"
msgstr ""
#: lib/bds/desktop/menu_bar.ex:83
#: lib/bds/desktop/shell_live.ex:1110
#, elixir-autogen, elixir-format
msgid "Connect to Server"
msgstr ""
#: lib/bds/desktop/menu_bar.ex:218
#, elixir-autogen, elixir-format
msgid "Connect to Server…"
msgstr ""
#: lib/bds/desktop/menu_bar.ex:84
#, elixir-autogen, elixir-format
msgid "Connection failed: %{reason}"
msgstr ""
#: lib/bds/desktop/menu_bar.ex:219
#, elixir-autogen, elixir-format
msgid "Disconnect from Server"
msgstr ""
#: lib/bds/desktop/menu_bar.ex:72
#: lib/bds/desktop/shell_live.ex:1097
#, elixir-autogen, elixir-format
msgid "Server address (user@host or user@host:port), public-key auth:"
msgstr ""
#: lib/bds/desktop/remote_connection.ex:82
#, elixir-autogen, elixir-format
msgid "Use the form user@host or user@host:port."
msgstr ""
#: lib/bds/tui.ex:539
#, elixir-autogen, elixir-format
msgid "Preview (ctrl+e to edit)"
msgstr ""
#: lib/bds/tui.ex:748
#, elixir-autogen, elixir-format
msgid "ctrl+s save · ctrl+p publish · ctrl+e preview · ctrl+t title · ctrl+l language · ctrl+g AI · esc back"
msgstr ""
#: lib/bds/tui.ex:403
#, elixir-autogen, elixir-format
msgid "All tasks finished."
msgstr ""
#: lib/bds/tui.ex:615
#, elixir-autogen, elixir-format
msgid "Commands — esc to close"
msgstr ""
#: lib/bds/tui.ex:247
#, elixir-autogen, elixir-format
msgid "Unknown command."
msgstr ""
#: lib/bds/tui.ex:605
#, elixir-autogen, elixir-format
msgid "[report/apply in GUI]"
msgstr ""
#: lib/bds/tui.ex:670
#, elixir-autogen, elixir-format
msgid "%{diffs} differences · %{orphans} orphan files"
msgstr ""
#: lib/bds/tui.ex:702
#, elixir-autogen, elixir-format
msgid "Expected %{expected} · Existing %{existing}"
msgstr ""
#: lib/bds/tui.ex:719
#, elixir-autogen, elixir-format
msgid "Extra files (will be removed):"
msgstr ""
#: lib/bds/tui.ex:715
#, elixir-autogen, elixir-format
msgid "Missing pages (will be rendered):"
msgstr ""
#: lib/bds/tui.ex:226
#, elixir-autogen, elixir-format
msgid "Nothing to apply."
msgstr ""
#: lib/bds/tui.ex:193
#, elixir-autogen, elixir-format
msgid "Nothing to repair."
msgstr ""
#: lib/bds/tui.ex:693
#, elixir-autogen, elixir-format
msgid "Orphan files (not in database):"
msgstr ""
#: lib/bds/tui.ex:709
#, elixir-autogen, elixir-format
msgid "Sitemap changed."
msgstr ""
#: lib/bds/tui.ex:723
#, elixir-autogen, elixir-format
msgid "Updated posts (will be re-rendered):"
msgstr ""
#: lib/bds/tui.ex:660
#, elixir-autogen, elixir-format
msgid "enter apply changes · esc close"
msgstr ""
#: lib/bds/tui.ex:741
#, elixir-autogen, elixir-format
msgid "enter open · n new post · 1-5 views · : commands (:? help) · r refresh · ctrl+q quit"
msgstr ""
#: lib/bds/tui.ex:655
#, elixir-autogen, elixir-format
msgid "enter repair all from files · esc close"
msgstr ""

59
specs/events.allium Normal file
View File

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

73
specs/server.allium Normal file
View File

@@ -0,0 +1,73 @@
-- allium: 1
-- Headless Server Mode (issue #26, phase 1)
-- Scope: extension (client/server split — SSH transport for TUI and GUI)
-- Distilled from: lib/bds/server.ex, lib/bds/application.ex, lib/bds/tui.ex
entity BootMode {
kind: desktop | server | tui
-- Resolved from the BDS_MODE environment variable at application start.
-- "server" is headless; "tui" is the headless server plus a TUI on the
-- launching terminal; anything else is desktop.
}
entity SshKeyMaterial {
dir: String -- <data dir>/ssh, next to the database
host_key: String -- ssh_host_rsa_key, generated on first boot
authorized_keys: String -- created empty (mode 600) on first boot
-- Invariant: key material lives in the private app data dir,
-- never in the repo or an application priv dir.
}
surface ServerRuntimeSurface {
facing _: ServerRuntime
provides:
ApplicationStarted(mode)
SshClientConnected()
GuiConnectRequested()
}
rule ModeResolution {
when: ApplicationStarted(mode)
-- BDS_MODE environment variable decides the mode once per boot
ensures: BootMode.created(kind: mode)
}
rule DesktopBoot {
when: ApplicationStarted(mode: desktop)
-- Unchanged behavior: wx window shell, loopback HTTP endpoint.
ensures: DesktopWindowStarted()
}
rule ServerBoot {
when: ApplicationStarted(mode: server)
-- Headless: no wx children at all. Works on macOS, Windows, Linux.
ensures: LoopbackEndpointStarted()
-- HTTP endpoint stays bound to 127.0.0.1; never exposed directly
ensures: CliSyncWatcherStarted()
ensures: SshDaemonStarted()
-- ExRatatui.SSH.Daemon: public-key auth only (no passwords),
-- tcpip_tunnel_out enabled so GUI clients tunnel to the loopback
-- endpoint through the same SSH connection and the same keys
ensures: SshKeyMaterial.created()
-- host key generated and authorized_keys touched on first boot only
}
rule GuiRemoteConnection {
when: GuiConnectRequested()
-- Desktop "Connect to Server…": ssh connect with the same public keys
-- (client identity + known_hosts in the private ssh dir), then an OTP
-- TCP/IP tunnel to the server's loopback endpoint; the webview loads
-- the local tunnel end. Disconnect returns to the local shell URL.
ensures: WebviewTunneledToServer()
}
rule TuiSession {
when: SshClientConnected()
-- Each SSH client gets its own TUI app instance on the server;
-- only terminal cells cross the wire.
ensures: TuiAppMounted()
-- UI locale comes from the server-side UI language setting,
-- identical for every client
}

106
specs/tui.allium Normal file
View File

@@ -0,0 +1,106 @@
-- allium: 1
-- Terminal UI (issue #26, phase 4)
-- Scope: extension (second renderer over the shared UI core)
-- Distilled from: lib/bds/tui.ex, lib/bds/ui/sidebar.ex, lib/bds/ui/post_editor/*.ex
entity TuiState {
view: posts | media | templates | scripts | tags
focus: sidebar | editor
selected_index: Integer
editing_post: Boolean
-- The editor drives the same BDS.UI.PostEditor workflow as the GUI:
-- canonical-language edits update the post, other languages update
-- translations; publish routes through the same publishing pipeline.
}
surface TuiSurface {
facing _: TuiRuntime
provides:
TuiKeyPressed(code, modifiers)
TuiEventReceived(entity, entity_id, action)
TuiSettingsChanged(key)
ShellTaskCompleted(route)
}
rule SidebarNavigation {
when: TuiKeyPressed(code: "up" | "down" | "j" | "k")
-- Selection moves over the flattened sidebar items and skips
-- section headers; data comes from BDS.UI.Sidebar.view, identical
-- to the GUI sidebar content.
ensures: TuiState.selected_index.updated()
}
rule OpenEntry {
when: TuiKeyPressed(code: "enter")
-- Posts open in the editor (title + textarea seeded from the
-- persisted form). Images open in the terminal image preview.
-- Other entities report that terminal editing is not available yet.
ensures: TuiState.editing_post.updated()
}
rule SaveAndPublish {
when: TuiKeyPressed(code: "s" | "p", modifiers: "ctrl")
-- ctrl+s saves the draft, ctrl+p saves and publishes — both through
-- BDS.UI.PostEditor.Persistence, so files, embeddings, search, and
-- the event bus behave exactly as a GUI save.
ensures: PostPersisted()
}
rule WrappedPreview {
when: TuiKeyPressed(code: "e", modifiers: "ctrl")
-- The editing textarea cannot soft-wrap (upstream ratatui-textarea
-- limitation), so ctrl+e toggles a read-only word-wrapped Markdown
-- preview of the current draft; keys in preview never edit content.
ensures: PreviewToggled()
}
rule AiQuickAction {
when: TuiKeyPressed(code: "g", modifiers: "ctrl")
-- One-shot AI post analysis (title/excerpt suggestions). Gated by
-- airplane mode: without a local endpoint the TUI shows a status
-- message instead of calling out.
ensures: AiSuggestionsRequestedOrRefused()
}
rule CommandPrompt {
when: TuiKeyPressed(code: ":")
-- Vi-style prompt: ":" opens a filterable list of the parameterless
-- Blog-menu shell commands (metadata diff, validate site, force
-- render, rebuilds, reindex, translations, duplicates, upload,
-- browser preview URL) and runs the selection through the same
-- BDS.Desktop.ShellCommands backend as the GUI menu; typing ":?"
-- shows the full list as help. Overlays clear the cells beneath
-- them. Commands whose follow-up UI is GUI-only (validate
-- translations, find duplicates) are marked. Queued tasks report
-- progress in the status line until all tasks finish.
ensures: CommandListShownOrExecuted()
}
rule ReportPanels {
when: ShellTaskCompleted(route: "metadata_diff" | "site_validation")
-- Completed metadata diff and site validation tasks open a
-- scrollable report panel showing the differences. Enter applies
-- the whole report — metadata diff repairs all items from the
-- filesystem (file → db) and imports orphan files; site validation
-- applies the full report incrementally (render missing, remove
-- extra, re-render updated), matching the GUI defaults. Esc closes
-- the report without applying. Reports completed before this
-- session started never pop up.
ensures: ReportShownThenAppliedOrCancelled()
}
rule MultiClientSync {
when: TuiEventReceived(entity, entity_id, action)
-- Domain events refresh the sidebar; a post deleted elsewhere closes
-- the open editor. Keeps TUI sessions synchronized with GUI clients
-- and pipeline ingestion.
ensures: TuiState.updated()
}
rule ServerSideLocale {
when: TuiSettingsChanged(key: "ui.language")
-- The TUI re-reads the server-side UI language and re-renders; all
-- clients always share one language.
ensures: TuiRelocalized()
}

View File

@@ -0,0 +1,98 @@
defmodule BDS.Desktop.RemoteConnectionTest do
use ExUnit.Case, async: false
alias BDS.Desktop.RemoteConnection
describe "parse_target/1" do
test "parses user@host with default SSH port" do
assert {:ok, %{user: "gb", host: "blog.example.com", port: 2222}} =
RemoteConnection.parse_target("gb@blog.example.com")
end
test "parses an explicit SSH port" do
assert {:ok, %{user: "gb", host: "10.0.0.5", port: 2022}} =
RemoteConnection.parse_target("gb@10.0.0.5:2022")
end
test "trims whitespace" do
assert {:ok, %{host: "h", user: "u"}} = RemoteConnection.parse_target(" u@h \n")
end
test "rejects targets without a user or host" do
assert {:error, :invalid_target} = RemoteConnection.parse_target("justahost")
assert {:error, :invalid_target} = RemoteConnection.parse_target("@host")
assert {:error, :invalid_target} = RemoteConnection.parse_target("user@")
assert {:error, :invalid_target} = RemoteConnection.parse_target("user@host:notaport")
end
end
describe "connect/2 and disconnect/1 (fake ssh)" do
setup do
parent = self()
ssh_dir =
Path.join(System.tmp_dir!(), "bds-remote-ssh-#{System.unique_integer([:positive])}")
on_exit(fn -> File.rm_rf!(ssh_dir) end)
fake = [
ssh_dir_fun: fn -> ssh_dir end,
connect_fun: fn host, port, opts ->
send(parent, {:ssh_connect, host, port, opts})
{:ok, spawn(fn -> Process.sleep(:infinity) end)}
end,
tunnel_fun: fn conn, _lhost, _lport, _rhost, rport, _timeout ->
send(parent, {:tunnel, conn, rport})
{:ok, 54_321}
end,
close_fun: fn conn -> send(parent, {:ssh_close, conn}) end
]
start_supervised!({RemoteConnection, name: nil, test_overrides: fake})
|> then(&{:ok, server: &1})
end
test "connects with public-key auth and returns the tunneled URL", %{server: server} do
assert {:ok, url} = RemoteConnection.connect(server, "gb@blog.example.com")
assert url == "http://127.0.0.1:54321/"
assert_receive {:ssh_connect, ~c"blog.example.com", 2222, opts}
assert opts[:user] == ~c"gb"
assert opts[:auth_methods] == ~c"publickey"
refute Keyword.has_key?(opts, :password)
assert_receive {:tunnel, _conn, 4010}
assert {:connected, "gb@blog.example.com", "http://127.0.0.1:54321/"} =
RemoteConnection.status(server)
end
test "disconnect closes the SSH connection", %{server: server} do
{:ok, _url} = RemoteConnection.connect(server, "gb@blog.example.com")
assert :ok = RemoteConnection.disconnect(server)
assert_receive {:ssh_close, _conn}
assert :disconnected = RemoteConnection.status(server)
end
test "connect errors surface as {:error, reason}", %{server: server} do
assert {:error, :invalid_target} = RemoteConnection.connect(server, "nonsense")
end
end
describe "menu integration" do
test "the File menu offers connect and disconnect" do
file_group = Enum.find(BDS.UI.MenuBar.default_groups(), &(&1.id == :file))
ids = Enum.map(file_group.items, &Map.get(&1, :id))
assert :connect_server in ids
assert :disconnect_server in ids
end
end
describe "desktop auth" do
test "required in desktop mode, skipped in server and tui modes" do
assert BDS.Server.desktop_auth_required?(:desktop) == true
assert BDS.Server.desktop_auth_required?(:server) == false
assert BDS.Server.desktop_auth_required?(:tui) == false
end
end
end

123
test/bds/events_test.exs Normal file
View File

@@ -0,0 +1,123 @@
defmodule BDS.EventsTest do
use ExUnit.Case, async: false
alias BDS.Events
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
:ok = Events.subscribe()
temp_dir = Path.join(System.tmp_dir!(), "bds-events-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "Events", data_path: temp_dir})
%{project: project}
end
describe "entity_changed/3" do
test "broadcasts the watcher-compatible payload" do
:ok = Events.entity_changed("post", "abc", :updated)
assert_receive {:entity_changed, %{entity: "post", entity_id: "abc", action: :updated}}
end
end
describe "posts broadcast" do
test "create, update, publish, delete", %{project: project} do
{:ok, post} = BDS.Posts.create_post(%{project_id: project.id, title: "Hello"})
post_id = post.id
assert_receive {:entity_changed, %{entity: "post", entity_id: ^post_id, action: :created}}
{:ok, _} = BDS.Posts.update_post(post_id, %{title: "Hello 2"})
assert_receive {:entity_changed, %{entity: "post", entity_id: ^post_id, action: :updated}}
{:ok, _} = BDS.Posts.publish_post(post_id)
assert_receive {:entity_changed, %{entity: "post", entity_id: ^post_id, action: :updated}}
{:ok, _} = BDS.Posts.delete_post(post_id)
assert_receive {:entity_changed, %{entity: "post", entity_id: ^post_id, action: :deleted}}
end
end
describe "tags broadcast" do
test "create, update, delete", %{project: project} do
{:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "elixir"})
tag_id = tag.id
assert_receive {:entity_changed, %{entity: "tag", entity_id: ^tag_id, action: :created}}
{:ok, _} = BDS.Tags.update_tag(tag_id, %{name: "erlang"})
assert_receive {:entity_changed, %{entity: "tag", entity_id: ^tag_id, action: :updated}}
{:ok, _} = BDS.Tags.delete_tag(tag_id)
assert_receive {:entity_changed, %{entity: "tag", entity_id: ^tag_id, action: :deleted}}
end
end
describe "templates broadcast" do
test "create, update, delete", %{project: project} do
{:ok, template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Article",
kind: :post,
content: "<article>{{ content }}</article>"
})
template_id = template.id
assert_receive {:entity_changed,
%{entity: "template", entity_id: ^template_id, action: :created}}
{:ok, _} = BDS.Templates.update_template(template_id, %{title: "Article 2"})
assert_receive {:entity_changed,
%{entity: "template", entity_id: ^template_id, action: :updated}}
{:ok, _} = BDS.Templates.delete_template(template_id)
assert_receive {:entity_changed,
%{entity: "template", entity_id: ^template_id, action: :deleted}}
end
end
describe "scripts broadcast" do
test "create, update, delete", %{project: project} do
{:ok, script} =
BDS.Scripts.create_script(%{
project_id: project.id,
title: "hello",
kind: :utility,
content: "function main() end"
})
script_id = script.id
assert_receive {:entity_changed, %{entity: "script", entity_id: ^script_id, action: :created}}
{:ok, _} = BDS.Scripts.update_script(script_id, %{title: "hello2"})
assert_receive {:entity_changed, %{entity: "script", entity_id: ^script_id, action: :updated}}
{:ok, _} = BDS.Scripts.delete_script(script_id)
assert_receive {:entity_changed, %{entity: "script", entity_id: ^script_id, action: :deleted}}
end
end
describe "settings broadcast" do
test "put_global_setting broadcasts settings_changed" do
:ok = BDS.Settings.put_global_setting("ui.git_diff_view_style", "split")
assert_receive {:settings_changed, "ui.git_diff_view_style"}
end
test "ui.language setting overrides the OS locale server-side" do
:ok = BDS.Settings.put_global_setting("ui.language", "de")
assert_receive {:settings_changed, "ui.language"}
assert BDS.I18n.current_ui_locale() == "de"
# Blank value falls back to the OS locale (en in the test env).
:ok = BDS.Settings.put_global_setting("ui.language", "")
assert BDS.I18n.current_ui_locale() == "en"
end
end
end

View File

@@ -55,43 +55,43 @@ defmodule BDS.GalleryPipelineTest do
test "counts [[gallery]] macro as gallery content" do test "counts [[gallery]] macro as gallery content" do
form = %{"content" => "Some text\n\n[[gallery]]\n\nMore text"} form = %{"content" => "Some text\n\n[[gallery]]\n\nMore text"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1 assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 1
end end
test "counts inline images as before" do test "counts inline images as before" do
form = %{"content" => "![Alt](image.jpg)"} form = %{"content" => "![Alt](image.jpg)"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1 assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 1
end end
test "counts both [[gallery]] and inline images" do test "counts both [[gallery]] and inline images" do
form = %{"content" => "![Alt](image.jpg)\n[[gallery]]"} form = %{"content" => "![Alt](image.jpg)\n[[gallery]]"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1 assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 1
end end
test "returns 0 when no gallery marks present" do test "returns 0 when no gallery marks present" do
form = %{"content" => "Just plain text"} form = %{"content" => "Just plain text"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 0 assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 0
end end
test "handles empty content" do test "handles empty content" do
form = %{} form = %{}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 0 assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 0
end end
test "counts multiple [[gallery]] occurrences" do test "counts multiple [[gallery]] occurrences" do
form = %{"content" => "[[gallery]] some text [[gallery]]"} form = %{"content" => "[[gallery]] some text [[gallery]]"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 2 assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 2
end end
test "is case insensitive for [[gallery]]" do test "is case insensitive for [[gallery]]" do
form = %{"content" => "[[Gallery]]"} form = %{"content" => "[[Gallery]]"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1 assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 1
end end
end end

View File

@@ -220,7 +220,7 @@ defmodule BDS.PostTranslationsTest do
"template_slug" => "" "template_slug" => ""
} }
alias BDS.Desktop.ShellLive.PostEditor.Persistence alias BDS.UI.PostEditor.Persistence
assert {:ok, _post} = Persistence.persist(post, draft, "en", metadata, :auto_save) assert {:ok, _post} = Persistence.persist(post, draft, "en", metadata, :auto_save)
assert ai_tasks() == [] assert ai_tasks() == []

View File

@@ -127,7 +127,9 @@ defmodule BDS.Scripting.JobTest do
_running_job = wait_for_job(job.id, &(&1.status == :running)) _running_job = wait_for_job(job.id, &(&1.status == :running))
assert :ok = BDS.Scripting.cancel_job(job.id) assert :ok = BDS.Scripting.cancel_job(job.id)
assert_receive :job_cleanup_ran, 1_000 # Generous timeout: cleanup runs in a separate shutdown path and 1s
# flakes when the full suite loads the machine.
assert_receive :job_cleanup_ran, 5_000
cancelled_job = wait_for_job(job.id, &(&1.status == :cancelled)) cancelled_job = wait_for_job(job.id, &(&1.status == :cancelled))
assert cancelled_job.finished_at != nil assert cancelled_job.finished_at != nil

121
test/bds/server_test.exs Normal file
View File

@@ -0,0 +1,121 @@
defmodule BDS.ServerTest do
use ExUnit.Case, async: false
alias BDS.Server
describe "mode/1" do
test "defaults to :desktop" do
assert Server.mode(nil) == :desktop
assert Server.mode("") == :desktop
end
test "recognizes server mode" do
assert Server.mode("server") == :server
assert Server.mode("SERVER") == :server
end
test "recognizes tui mode" do
assert Server.mode("tui") == :tui
end
test "unknown values fall back to :desktop" do
assert Server.mode("garbage") == :desktop
end
end
describe "ensure_ssh_dir!/1" do
setup do
dir = Path.join(System.tmp_dir!(), "bds-ssh-test-#{System.unique_integer([:positive])}")
on_exit(fn -> File.rm_rf!(dir) end)
{:ok, dir: dir}
end
test "creates the directory and an empty authorized_keys file", %{dir: dir} do
assert Server.ensure_ssh_dir!(dir) == dir
assert File.dir?(dir)
keys_path = Path.join(dir, "authorized_keys")
assert File.exists?(keys_path)
assert File.read!(keys_path) == ""
%File.Stat{mode: mode} = File.stat!(keys_path)
assert Bitwise.band(mode, 0o777) == 0o600
end
test "leaves an existing authorized_keys file untouched", %{dir: dir} do
File.mkdir_p!(dir)
keys_path = Path.join(dir, "authorized_keys")
File.write!(keys_path, "ssh-ed25519 AAAA... user@host\n")
Server.ensure_ssh_dir!(dir)
assert File.read!(keys_path) == "ssh-ed25519 AAAA... user@host\n"
end
end
describe "daemon_opts/1" do
setup do
dir = Path.join(System.tmp_dir!(), "bds-ssh-test-#{System.unique_integer([:positive])}")
on_exit(fn -> File.rm_rf!(dir) end)
{:ok, dir: dir}
end
test "serves the TUI app over public-key auth only, with GUI tunneling", %{dir: dir} do
opts = Server.daemon_opts(dir)
assert opts[:mod] == BDS.TUI
assert opts[:auth_methods] == ~c"publickey"
assert opts[:tcpip_tunnel_out] == true
refute Keyword.has_key?(opts, :pwdfun)
assert opts[:system_dir] == String.to_charlist(dir)
assert opts[:user_dir] == String.to_charlist(dir)
end
test "generates a host key on first use and reuses it", %{dir: dir} do
Server.daemon_opts(dir)
key_path = Path.join(dir, "ssh_host_rsa_key")
assert File.exists?(key_path)
first = File.read!(key_path)
Server.daemon_opts(dir)
assert File.read!(key_path) == first
end
test "uses the configured SSH port", %{dir: dir} do
assert Server.daemon_opts(dir)[:port] == Application.get_env(:bds, :server)[:ssh_port]
end
end
describe "BDS.Application.mode_children/2" do
test "server mode starts endpoint, cli sync watcher, and the SSH daemon" do
children = BDS.Application.mode_children(:server, :prod)
assert {BDS.Desktop.Server, []} in children
assert BDS.CliSync.Watcher in children
# BDS.Server wraps ExRatatui.SSH.Daemon, deferring key generation to
# process start so building this list never touches the data dir.
assert BDS.Server in children
end
test "server mode starts no wx window children" do
children = BDS.Application.mode_children(:server, :prod)
refute Enum.any?(children, fn
{Desktop.Window, _} -> true
_other -> false
end)
end
test "tui mode is the server plus a local TUI that stops the VM on quit" do
assert BDS.Application.mode_children(:tui, :prod) ==
BDS.Application.mode_children(:server, :prod) ++
[{BDS.TUI, [stop_vm_on_exit: true]}]
end
test "desktop mode delegates to desktop_children" do
assert BDS.Application.mode_children(:desktop, :test) ==
BDS.Application.desktop_children(:test)
end
end
end

434
test/bds/tui_test.exs Normal file
View File

@@ -0,0 +1,434 @@
defmodule BDS.TUITest do
use ExUnit.Case, async: false
import Ecto.Query
alias ExRatatui.CellSession
alias ExRatatui.Event.Key
defp post_count(project_id) do
BDS.Repo.aggregate(from(p in BDS.Posts.Post, where: p.project_id == ^project_id), :count)
end
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, :manual) end)
temp_dir = Path.join(System.tmp_dir!(), "bds-tui-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "TUI", data_path: temp_dir})
{:ok, _} = BDS.Projects.set_active_project(project.id)
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Hello TUI",
content: "terminal body",
language: "en"
})
%{project: project, post: post}
end
defp mount!(opts \\ []) do
{:ok, state} = BDS.TUI.mount(opts)
state
end
defp screen_text(state, width \\ 100, height \\ 32) do
session = CellSession.new(width, height)
frame = %ExRatatui.Frame{width: width, height: height}
:ok = CellSession.draw(session, BDS.TUI.render(state, frame))
snapshot = CellSession.take_cells(session)
:ok = CellSession.close(session)
snapshot.cells
|> Enum.group_by(& &1.row)
|> Enum.sort_by(fn {row, _} -> row end)
|> Enum.map_join("\n", fn {_row, cells} ->
cells |> Enum.sort_by(& &1.col) |> Enum.map_join("", & &1.symbol)
end)
end
defp key(code, modifiers \\ []), do: %Key{code: code, kind: "press", modifiers: modifiers}
defp press(state, code, modifiers \\ []) do
{:noreply, state} = BDS.TUI.handle_event(key(code, modifiers), state)
state
end
test "mounts against the active project and lists posts", %{post: post} do
state = mount!()
assert state.project_id == post.project_id
text = screen_text(state)
assert text =~ "Hello TUI"
end
test "navigates and opens a post in the editor", %{post: post} do
state = mount!() |> press("enter")
assert state.focus == :editor
assert state.editor.post.id == post.id
assert ExRatatui.textarea_get_value(state.editor.textarea) == "terminal body"
assert screen_text(state) =~ "terminal body"
end
test "ctrl+s persists textarea edits", %{post: post} do
state = mount!() |> press("enter")
:ok = ExRatatui.textarea_insert_str(state.editor.textarea, "more words ")
state = press(state, "s", ["ctrl"])
assert state.editor.dirty == false
assert BDS.Posts.get_post(post.id).content =~ "more words"
end
test "ctrl+p publishes the open post", %{post: post} do
state = mount!() |> press("enter") |> press("p", ["ctrl"])
published = BDS.Posts.get_post(post.id)
assert published.status == :published
assert state.editor.post.status == :published
end
test "n creates a new draft post", %{project: project} do
count_before = post_count(project.id)
state = mount!() |> press("n")
assert post_count(project.id) == count_before + 1
assert state.focus == :editor
assert state.editor.post.status == :draft
end
test "esc returns focus to the sidebar" do
state = mount!() |> press("enter") |> press("esc")
assert state.focus == :sidebar
end
test "ctrl+e toggles a word-wrapped preview of the draft", %{post: post} do
long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line})
state = mount!() |> press("enter") |> press("e", ["ctrl"])
assert state.editor.preview?
# The textarea cannot soft-wrap, so the tail of a long line is off-screen
# there; the preview renders through the Markdown widget, which wraps.
assert screen_text(state, 80, 32) =~ "FINALWORD"
state = press(state, "e", ["ctrl"])
refute state.editor.preview?
end
test "keys in preview mode do not edit the content" do
state = mount!() |> press("enter") |> press("e", ["ctrl"])
before = ExRatatui.textarea_get_value(state.editor.textarea)
state = press(state, "x")
assert ExRatatui.textarea_get_value(state.editor.textarea) == before
refute state.editor.dirty
end
test "esc in preview returns to editing, not to the sidebar" do
state = mount!() |> press("enter") |> press("e", ["ctrl"]) |> press("esc")
refute state.editor.preview?
assert state.focus == :editor
end
test "entity_changed refreshes the sidebar", %{project: project} do
state = mount!()
{:ok, other} =
BDS.Posts.create_post(%{project_id: project.id, title: "From Pipeline", content: "x"})
{:noreply, state} =
BDS.TUI.handle_info(
{:entity_changed, %{entity: "post", entity_id: other.id, action: :created}},
state
)
assert screen_text(state) =~ "From Pipeline"
end
test "view switching shows media view" do
state = mount!() |> press("2")
assert state.view == "media"
end
test "quit key stops the app" do
assert {:stop, _state} = BDS.TUI.handle_event(key("q", ["ctrl"]), mount!())
end
describe "command prompt" do
defp mount_with_executor! do
parent = self()
mount!(
command_executor: fn action, params ->
send(parent, {:executed, action, params})
{:ok, %{kind: "output", title: "T", message: "done"}}
end
)
end
test "':' opens the prompt and lists commands" do
state = mount_with_executor!() |> press(":")
assert state.command != nil
text = screen_text(state)
assert text =~ "metadata_diff"
assert text =~ "validate_site"
end
test "the palette clears the background underneath" do
state = mount_with_executor!()
assert screen_text(state) =~ "Select an entry"
state = press(state, ":")
refute screen_text(state) =~ "Select an entry"
end
test "'?' alone does nothing; ':?' opens the command help with GUI markers" do
state = mount_with_executor!() |> press("?")
assert state.command == nil
state = state |> press(":") |> press("?")
assert state.command.help?
text = screen_text(state)
assert text =~ "metadata_diff"
# Commands whose report/apply UI lives in the GUI are marked.
assert text =~ "GUI"
state = press(state, "esc")
assert state.command == nil
end
test "typing filters and enter runs the selected command" do
state =
mount_with_executor!()
|> press(":")
|> press("m")
|> press("e")
|> press("t")
|> press("a")
|> press("enter")
assert_receive {:executed, "metadata_diff", %{}}
assert state.command == nil
assert state.status =~ "done"
end
test "up/down move the selection" do
state = mount_with_executor!() |> press(":") |> press("down") |> press("enter")
assert_receive {:executed, action, %{}}
assert is_binary(action)
end
test "input that matches no command renders without errors" do
state = mount_with_executor!() |> press(":") |> press("j")
# Filtering down to zero commands must not blow up the render
# (List panics on a selected index into an empty item list).
text = screen_text(state)
assert text =~ ":j"
end
test "no match reports an unknown command" do
state =
mount_with_executor!()
|> press(":")
|> press("z")
|> press("z")
|> press("enter")
refute_receive {:executed, _action, _params}, 50
assert state.command == nil
assert state.status != nil
end
test "a queued task starts status polling and finishes with a toast" do
parent = self()
state =
mount!(
command_executor: fn action, _params ->
send(parent, {:executed, action})
{:ok, %{kind: "task_queued", title: "Rebuild", message: "queued"}}
end
)
|> press(":")
|> press("enter")
assert_receive {:executed, _action}
assert state.task_polling
# No tasks are actually running in this test, so the first poll ends it.
{:noreply, state} = BDS.TUI.handle_info(:poll_tasks, state)
refute state.task_polling
assert state.status != nil
end
end
describe "report panels" do
defp diff_snapshot do
%{
running_task_message: nil,
tasks: [
%{
id: "task-diff",
status: :completed,
result: %{
kind: "open_editor",
route: "metadata_diff",
title: "Metadata Diff",
payload: %{
summary: %{diff_count: 1, orphan_count: 1},
diff_reports: [
%{
"entity_type" => "post",
"entity_id" => "p1",
"differences" => [
%{"name" => "title", "db_value" => "Old", "file_value" => "New"}
]
}
],
orphan_reports: [%{"file_path" => "posts/2026/07/lost.md"}]
}
}
}
]
}
end
defp validation_snapshot do
%{
running_task_message: nil,
tasks: [
%{
id: "task-validate",
status: :completed,
result: %{
kind: "open_editor",
route: "site_validation",
title: "Site Validation",
payload: %{
sitemap_path: "sitemap.xml",
sitemap_changed: true,
summary: %{
expected_count: 10,
existing_count: 8,
missing_count: 2,
extra_count: 1,
updated_count: 1
},
missing_url_paths: ["/2026/07/a/", "/2026/07/b/"],
extra_url_paths: ["/stale/"],
updated_post_url_paths: ["/2026/07/c/"],
expected_url_count: 10,
existing_html_url_count: 8
}
}
}
]
}
end
defp mount_for_report!(snapshot) do
parent = self()
state =
mount!(
command_executor: fn action, params ->
send(parent, {:executed, action, params})
{:ok, %{kind: "task_queued", title: "T", message: "queued"}}
end,
task_snapshot_fun: fn -> snapshot end,
# Pre-existing completed tasks must not pop up reports, so the
# tests hand the snapshot in only after mount via this switch.
initial_task_snapshot: %{running_task_message: nil, tasks: []}
)
{:noreply, state} = BDS.TUI.handle_info(:poll_tasks, %{state | task_polling: true})
state
end
test "a completed metadata diff task opens the report panel" do
state = mount_for_report!(diff_snapshot())
assert state.report.route == "metadata_diff"
text = screen_text(state)
assert text =~ "post p1"
assert text =~ "title"
assert text =~ "lost.md"
end
test "esc closes the report without applying" do
state = mount_for_report!(diff_snapshot()) |> press("esc")
assert state.report == nil
refute_receive {:executed, _action, _params}, 50
end
test "enter repairs all metadata diffs from files and imports orphans" do
state = mount_for_report!(diff_snapshot()) |> press("enter")
assert state.report == nil
assert_receive {:executed, "repair_metadata_diff", %{items: items, direction: "file_to_db"}}
assert [%{"entity_type" => "post", "entity_id" => "p1"} | _] = items
assert_receive {:executed, "import_metadata_diff_orphans", %{orphans: orphans}}
assert [%{"file_path" => "posts/2026/07/lost.md"}] = orphans
end
test "a completed site validation task opens the report and enter applies it" do
state = mount_for_report!(validation_snapshot())
assert state.report.route == "site_validation"
text = screen_text(state)
assert text =~ "/2026/07/a/"
assert text =~ "/stale/"
state = press(state, "enter")
assert state.report == nil
assert_receive {:executed, "apply_site_validation", %{report: report}}
assert report.missing_url_paths == ["/2026/07/a/", "/2026/07/b/"]
end
test "completed tasks from before mount never open a report" do
parent = self()
state =
mount!(
command_executor: fn action, params -> send(parent, {:executed, action, params}) end,
task_snapshot_fun: fn -> diff_snapshot() end
)
{:noreply, state} = BDS.TUI.handle_info(:poll_tasks, %{state | task_polling: true})
assert state.report == nil
end
end
test "local tui mode stops the VM when the app exits" do
parent = self()
state = mount!(stop_vm_on_exit: true, stop_fun: fn -> send(parent, :vm_stopped) end)
assert :ok = BDS.TUI.terminate(:normal, state)
assert_receive :vm_stopped
end
test "ssh-served sessions never stop the VM on exit" do
parent = self()
state = mount!(stop_fun: fn -> send(parent, :vm_stopped) end)
assert :ok = BDS.TUI.terminate(:normal, state)
refute_receive :vm_stopped, 100
end
end

View File

@@ -0,0 +1,64 @@
defmodule BDS.UI.PostEditorTest do
use ExUnit.Case, async: false
alias BDS.UI.PostEditor.{Draft, Metadata, Persistence}
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-ui-pe-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "UI PostEditor", data_path: temp_dir})
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "First Post",
content: "hello world",
tags: ["elixir"],
language: "en"
})
%{project: project, post: post}
end
test "persisted_form builds the canonical draft", %{post: post} do
metadata = Metadata.project_metadata(post.project_id)
form = Draft.persisted_form(post, metadata, "en")
assert form["title"] == "First Post"
assert form["content"] == "hello world"
assert form["tags"] == "elixir"
assert form["language"] == "en"
end
test "persist saves an edited draft and publish publishes it", %{post: post} do
metadata = Metadata.project_metadata(post.project_id)
form = Draft.persisted_form(post, metadata, "en")
draft = %{form | "title" => "Renamed Post", "content" => "updated body"}
assert {:ok, saved} = Persistence.persist(post, draft, "en", metadata, :save)
assert saved.title == "Renamed Post"
assert saved.status == :draft
form = Draft.persisted_form(saved, metadata, "en")
assert {:ok, published} = Persistence.persist(saved, form, "en", metadata, :publish)
assert published.status == :published
assert published.file_path != ""
end
test "discard on an unpublished draft is a no-op success", %{post: post} do
metadata = Metadata.project_metadata(post.project_id)
assert {:ok, _post} = Persistence.discard(post, "en", metadata)
end
test "normalize_params keeps the active language on same-language edits" do
params = %{"title" => "T", "language" => "DE "}
assert Draft.normalize_params(params, "de", "de")["language"] == "de"
assert Draft.normalize_params(params, "de", "fr")["language"] == "fr"
end
end