Compare commits
11 Commits
6ec61610f1
...
a8ca3b643b
| Author | SHA1 | Date | |
|---|---|---|---|
| a8ca3b643b | |||
| 39bb232b57 | |||
| 824313f106 | |||
| 01b90e00a0 | |||
| c77686250e | |||
| 48e0903542 | |||
| df2bdc2041 | |||
| a3ca44640c | |||
| 3a8fafd2fd | |||
| 16f45d77b2 | |||
| 700ed79c43 |
2
API.md
2
API.md
@@ -150,7 +150,7 @@ local result = bds.app.get_default_project_path()
|
||||
|
||||
### app.get_system_language
|
||||
|
||||
Return the current UI locale.
|
||||
Return the current UI locale (the server-side UI language setting, falling back to the OS locale when unset).
|
||||
|
||||
**Parameters**
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- [Generating and publishing](#generating-and-publishing)
|
||||
- [Typical editorial workflows](#typical-editorial-workflows)
|
||||
- [Working fully offline](#working-fully-offline)
|
||||
- [Running a headless server and the terminal UI](#running-a-headless-server-and-the-terminal-ui)
|
||||
- [Troubleshooting and recovery](#troubleshooting-and-recovery)
|
||||
- [Team conventions](#team-conventions)
|
||||
|
||||
@@ -443,6 +444,29 @@ When AI is involved, airplane mode determines which automatic actions are allowe
|
||||
|
||||
---
|
||||
|
||||
## Running a headless server and the terminal UI
|
||||
|
||||
bDS2 can run without a window on a server (for example a Linux VPS) and be used from several places at once. Start it with `BDS_MODE=server`; the same release binary that runs the desktop app then runs headless. All clients connect through one SSH port (default 2222) — the web endpoint itself stays private on the server.
|
||||
|
||||
Access is controlled by SSH keys, not passwords. On first start the server creates an `ssh/` folder next to its database (for example `~/Library/Application Support/BDS2/ssh/` on macOS) containing the host key and an empty `authorized_keys` file. Paste the public keys of every machine that may connect into `authorized_keys`, one per line — the same file format OpenSSH uses.
|
||||
|
||||
To work in a terminal, connect with plain `ssh -p 2222 user@server`: the terminal UI opens directly. You can browse posts, media, templates, scripts, and tags, create and edit posts, publish, preview images, and run the one-shot AI actions. The status line at the bottom always shows the available keys.
|
||||
|
||||
To work in the desktop app against a remote server, use **File → Connect to Server…** and enter `user@host` (or `user@host:port`). The app connects with the SSH key from its own local `ssh/` folder and shows the remote workspace in the window; **File → Disconnect from Server** returns to the local workspace. A plain browser works too, through a manual SSH tunnel (`ssh -p 2222 -L 4010:127.0.0.1:4010 user@server`, then open `http://127.0.0.1:4010`).
|
||||
|
||||
Everything you see stays synchronized: when a script, another client, or a pipeline changes content on the server, every connected terminal and window updates. The interface language is a server-side setting — changing it in any client changes it for all of them.
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- `BDS_MODE=server` runs the same app headless; only the SSH port is exposed.
|
||||
- Access is public-key only: manage `authorized_keys` in the server's `ssh/` folder.
|
||||
- `ssh` gives the terminal UI; **File → Connect to Server…** gives the desktop app; both use the same keys.
|
||||
- All connected clients stay synchronized and share one interface language.
|
||||
|
||||
[↑ Back to In this article](#in-this-article)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting and recovery
|
||||
|
||||
If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote.
|
||||
|
||||
65
README.md
65
README.md
@@ -12,6 +12,7 @@ The major architectural rework is in place.
|
||||
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
|
||||
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
|
||||
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
|
||||
- The app boots in three modes: the desktop app, a headless server (SSH-served terminal UI + tunneled GUI), and a local TUI — see [Headless Server Mode And Terminal UI](#headless-server-mode-and-terminal-ui).
|
||||
|
||||
The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
|
||||
|
||||
@@ -156,6 +157,70 @@ mix dialyzer
|
||||
mix assets.build
|
||||
```
|
||||
|
||||
## Headless Server Mode And Terminal UI
|
||||
|
||||
Since issue #26 the app has three boot modes, selected with the `BDS_MODE`
|
||||
environment variable (any release or dev boot; default is the desktop app):
|
||||
|
||||
- `desktop` — the native desktop app, unchanged.
|
||||
- `server` — headless: no window, loopback-only HTTP endpoint, CLI sync
|
||||
watcher, and an SSH daemon serving the terminal UI. Works on macOS,
|
||||
Windows, and Linux (no display needed).
|
||||
- `tui` — the headless server plus a TUI attached to the launching terminal.
|
||||
|
||||
### Build and run a headless server
|
||||
|
||||
```bash
|
||||
MIX_ENV=prod mix release bds --overwrite
|
||||
BDS_MODE=server _build/prod/rel/bds/bin/bds start
|
||||
```
|
||||
|
||||
The SSH daemon listens on port `2222` (`BDS_SSH_PORT` or
|
||||
`config :bds, :server, ssh_port:` to change). Authentication is public-key
|
||||
only: on first boot the server generates a host key and an empty
|
||||
`authorized_keys` (mode 600) in `ssh/` next to the database (default
|
||||
`~/Library/Application Support/BDS2/ssh/`, or under `BDS_DATABASE_PATH`).
|
||||
Add your public key there, then connect from any terminal:
|
||||
|
||||
```bash
|
||||
ssh -p 2222 <server-host> # opens the TUI
|
||||
```
|
||||
|
||||
Each SSH connection gets its own TUI session on the server; only terminal
|
||||
cells cross the wire. All clients stay synchronized through the domain
|
||||
event bus (`BDS.Events`) — a post created by a pipeline or another client
|
||||
appears in every open shell.
|
||||
|
||||
### TUI keys
|
||||
|
||||
`↑/↓` or `j/k` navigate · `enter` open · `n` new post · `1-5` switch view
|
||||
(posts/media/templates/scripts/tags) · `:` command prompt (metadata
|
||||
diff, validate site, force render, rebuilds, …; `:?` lists all) ·
|
||||
metadata-diff/site-validation reports open as panels — `enter` applies
|
||||
the whole report (repair from files / incremental apply), `esc` cancels ·
|
||||
`r` refresh · in the editor:
|
||||
`ctrl+s` save, `ctrl+p` publish, `ctrl+e` word-wrapped preview,
|
||||
`ctrl+t` title, `ctrl+l` language, `ctrl+g` AI suggestions
|
||||
(airplane-mode gated), `esc` back · `ctrl+q` quit.
|
||||
Images preview inline via the terminal image protocol. The UI language is
|
||||
the server-side setting shared by every client.
|
||||
|
||||
### Remote GUI
|
||||
|
||||
The HTTP endpoint stays bound to `127.0.0.1` and is never exposed; GUI
|
||||
clients tunnel through the same SSH daemon and keys:
|
||||
|
||||
```bash
|
||||
ssh -p 2222 -L 4010:127.0.0.1:4010 -N <server-host> &
|
||||
open http://127.0.0.1:4010
|
||||
```
|
||||
|
||||
Or use the desktop app directly: **File → Connect to Server…** takes
|
||||
`user@host[:port]`, connects with the client key from the same local
|
||||
`ssh/` directory (public-key auth, trust-on-first-use `known_hosts`),
|
||||
tunnels to the server, and points the window at it. **File → Disconnect
|
||||
from Server** returns to the local workspace.
|
||||
|
||||
## Monaco Editor
|
||||
|
||||
Monaco is bundled via esbuild using its ESM entry point (`monaco-editor/esm/vs/editor/editor.main.js`). The
|
||||
|
||||
@@ -25,8 +25,13 @@ config :bds, :desktop,
|
||||
window_min_size: {800, 600},
|
||||
title: "Blogging Desktop Server"
|
||||
|
||||
config :bds, :server, ssh_port: 2222
|
||||
|
||||
config :bds, BDS.Desktop.Endpoint,
|
||||
url: [host: "127.0.0.1"],
|
||||
# Any-port loopback origins: the remote-GUI SSH tunnel terminates on an
|
||||
# ephemeral local port, so the websocket origin port varies per connect.
|
||||
check_origin: ["//127.0.0.1", "//localhost"],
|
||||
adapter: Bandit.PhoenixAdapter,
|
||||
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
|
||||
pubsub_server: BDS.PubSub,
|
||||
|
||||
@@ -5,6 +5,22 @@ defmodule BDS.Application do
|
||||
|
||||
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
|
||||
|
||||
# Children that depend on the boot mode (issue #26): desktop mode runs the
|
||||
# wx window shell, server mode runs headless behind the SSH daemon. Both
|
||||
# start the loopback HTTP endpoint and the CLI sync watcher.
|
||||
def mode_children(:server, _env) do
|
||||
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher, BDS.Server]
|
||||
end
|
||||
|
||||
# Local TUI: the headless server plus a TUI on the launching terminal, so
|
||||
# a GUI (via tunnel) and the local terminal can work in parallel. Quitting
|
||||
# this TUI stops the whole VM — the terminal is the app in this mode.
|
||||
# SSH-served TUI sessions never get this flag and stay session-local.
|
||||
def mode_children(:tui, env),
|
||||
do: mode_children(:server, env) ++ [{BDS.TUI, [stop_vm_on_exit: true]}]
|
||||
|
||||
def mode_children(:desktop, env), do: desktop_children(env)
|
||||
|
||||
def desktop_children(env \\ nil)
|
||||
|
||||
def desktop_children(:test) do
|
||||
@@ -41,7 +57,7 @@ defmodule BDS.Application do
|
||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||
BDS.Scripting.JobSupervisor,
|
||||
BDS.Embeddings.Index
|
||||
] ++ embedding_children() ++ desktop_children(current_env())
|
||||
] ++ embedding_children() ++ mode_children(BDS.Server.mode(), current_env())
|
||||
|
||||
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
@@ -77,7 +93,8 @@ defmodule BDS.Application do
|
||||
[
|
||||
{Desktop.Window, window_opts},
|
||||
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher),
|
||||
{BDS.Desktop.DeepLink, []}
|
||||
{BDS.Desktop.DeepLink, []},
|
||||
{BDS.Desktop.RemoteConnection, []}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
52
lib/bds/desktop/dialogs.ex
Normal file
52
lib/bds/desktop/dialogs.ex
Normal file
@@ -0,0 +1,52 @@
|
||||
defmodule BDS.Desktop.Dialogs do
|
||||
@moduledoc false
|
||||
|
||||
# Native single-line text prompt, same osascript approach as
|
||||
# BDS.Desktop.FilePicker. Returns {:ok, text} | :cancel | {:error, map}.
|
||||
def prompt_text(message, default \\ "") when is_binary(message) do
|
||||
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
|
||||
:cancel
|
||||
else
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> prompt_text_macos(message, default)
|
||||
_other -> {:error, %{message: "Dialogs are only supported on macOS desktop"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def alert(title, message) when is_binary(title) and is_binary(message) do
|
||||
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" or :os.type() != {:unix, :darwin} do
|
||||
:ok
|
||||
else
|
||||
script = "display alert \"#{escape(title)}\" message \"#{escape(message)}\""
|
||||
_ = System.cmd("osascript", ["-e", script], stderr_to_stdout: true)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp prompt_text_macos(message, default) do
|
||||
script =
|
||||
"text returned of (display dialog \"#{escape(message)}\" default answer \"#{escape(default)}\")"
|
||||
|
||||
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
|
||||
{output, 0} -> {:ok, String.trim(output)}
|
||||
{output, _status} -> normalize_failure(output)
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_failure(output) do
|
||||
message = String.trim(output)
|
||||
|
||||
if message == "" or String.contains?(String.downcase(message), "canceled") do
|
||||
:cancel
|
||||
else
|
||||
{:error, %{message: message}}
|
||||
end
|
||||
end
|
||||
|
||||
defp escape(value) do
|
||||
value
|
||||
|> String.replace("\\", "\\\\")
|
||||
|> String.replace("\"", "\\\"")
|
||||
end
|
||||
end
|
||||
@@ -23,7 +23,8 @@ defmodule BDS.Desktop.Endpoint do
|
||||
plug(BDS.Desktop.Router)
|
||||
|
||||
defp maybe_require_desktop_auth(conn, _opts) do
|
||||
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do
|
||||
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] or
|
||||
not BDS.Server.desktop_auth_required?() do
|
||||
conn
|
||||
else
|
||||
Desktop.Auth.call(conn, [])
|
||||
|
||||
@@ -67,6 +67,35 @@ defmodule BDS.Desktop.MenuBar do
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event("connect_server", menu) do
|
||||
prompt =
|
||||
dgettext("ui", "Server address (user@host or user@host:port), public-key auth:")
|
||||
|
||||
with {:ok, target} <- BDS.Desktop.Dialogs.prompt_text(prompt, "user@host"),
|
||||
{:ok, url} <- BDS.Desktop.RemoteConnection.connect(target) do
|
||||
Window.show(BDS.Desktop.MainWindow.window_id(), url)
|
||||
else
|
||||
:cancel ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
BDS.Desktop.Dialogs.alert(
|
||||
dgettext("ui", "Connect to Server"),
|
||||
dgettext("ui", "Connection failed: %{reason}",
|
||||
reason: BDS.Desktop.RemoteConnection.format_reason(reason)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event("disconnect_server", menu) do
|
||||
:ok = BDS.Desktop.RemoteConnection.disconnect()
|
||||
Window.show(BDS.Desktop.MainWindow.window_id(), BDS.Desktop.url())
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
def handle_event("open_data_folder", menu) do
|
||||
_ = BDS.Desktop.ShellCommands.execute("open_data_folder")
|
||||
{:noreply, menu}
|
||||
@@ -186,6 +215,8 @@ defmodule BDS.Desktop.MenuBar do
|
||||
defp item_label(:import_media), do: dgettext("ui", "Import Media")
|
||||
defp item_label(:save), do: dgettext("ui", "Save")
|
||||
defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
|
||||
defp item_label(:connect_server), do: dgettext("ui", "Connect to Server…")
|
||||
defp item_label(:disconnect_server), do: dgettext("ui", "Disconnect from Server")
|
||||
defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
|
||||
defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
|
||||
defp item_label(:quit), do: dgettext("ui", "Quit")
|
||||
|
||||
180
lib/bds/desktop/remote_connection.ex
Normal file
180
lib/bds/desktop/remote_connection.ex
Normal file
@@ -0,0 +1,180 @@
|
||||
defmodule BDS.Desktop.RemoteConnection do
|
||||
@moduledoc """
|
||||
GUI remote mode (issue #26, phase 5).
|
||||
|
||||
Connects the desktop app to a headless bDS2 server over the same SSH
|
||||
channel (and the same public keys) the TUI uses: `:ssh.connect` with
|
||||
public-key auth, then an OTP TCP/IP tunnel to the server's loopback HTTP
|
||||
endpoint. The webview then loads the local tunnel end — the server never
|
||||
exposes HTTP.
|
||||
|
||||
Client key material lives in the same private `ssh/` directory next to
|
||||
the database (`BDS.Server.ssh_dir/0`): an `id_ed25519`/`id_rsa` identity
|
||||
and a `known_hosts` written on first connect.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
require Logger
|
||||
|
||||
@default_ssh_port 2222
|
||||
@tunnel_timeout_ms 10_000
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
case Keyword.pop(opts, :name, __MODULE__) do
|
||||
{nil, init_opts} -> GenServer.start_link(__MODULE__, init_opts)
|
||||
{name, init_opts} -> GenServer.start_link(__MODULE__, init_opts, name: name)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses `user@host[:port]` into connection parameters. The port is the
|
||||
server's SSH port (default #{@default_ssh_port}).
|
||||
"""
|
||||
@spec parse_target(String.t()) ::
|
||||
{:ok, %{user: String.t(), host: String.t(), port: pos_integer()}}
|
||||
| {:error, :invalid_target}
|
||||
def parse_target(target) when is_binary(target) do
|
||||
with [user, rest] when user != "" <- String.split(String.trim(target), "@", parts: 2),
|
||||
{:ok, host, port} <- parse_host_port(rest) do
|
||||
{:ok, %{user: user, host: host, port: port}}
|
||||
else
|
||||
_other -> {:error, :invalid_target}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_host_port(rest) do
|
||||
case String.split(rest, ":", parts: 2) do
|
||||
[host] when host != "" ->
|
||||
{:ok, host, @default_ssh_port}
|
||||
|
||||
[host, port] when host != "" ->
|
||||
case Integer.parse(port) do
|
||||
{number, ""} when number > 0 -> {:ok, host, number}
|
||||
_other -> :error
|
||||
end
|
||||
|
||||
_other ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Connects and tunnels; returns the local URL the webview should load."
|
||||
@spec connect(GenServer.server(), String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||
def connect(server \\ __MODULE__, target) do
|
||||
GenServer.call(server, {:connect, target}, @tunnel_timeout_ms + 5_000)
|
||||
end
|
||||
|
||||
@spec disconnect(GenServer.server()) :: :ok
|
||||
def disconnect(server \\ __MODULE__) do
|
||||
GenServer.call(server, :disconnect)
|
||||
end
|
||||
|
||||
@spec status(GenServer.server()) :: :disconnected | {:connected, String.t(), String.t()}
|
||||
def status(server \\ __MODULE__) do
|
||||
GenServer.call(server, :status)
|
||||
end
|
||||
|
||||
@doc "Human-readable connection error for menu/shell error reporting."
|
||||
@spec format_reason(term()) :: String.t()
|
||||
def format_reason(:invalid_target) do
|
||||
dgettext("ui", "Use the form user@host or user@host:port.")
|
||||
end
|
||||
|
||||
def format_reason(%{message: message}) when is_binary(message), do: message
|
||||
def format_reason(reason), do: inspect(reason)
|
||||
|
||||
## GenServer callbacks
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
overrides = Keyword.get(opts, :test_overrides, [])
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
conn: nil,
|
||||
monitor: nil,
|
||||
target: nil,
|
||||
url: nil,
|
||||
connect_fun: Keyword.get(overrides, :connect_fun, &:ssh.connect/3),
|
||||
tunnel_fun: Keyword.get(overrides, :tunnel_fun, &:ssh.tcpip_tunnel_to_server/6),
|
||||
close_fun: Keyword.get(overrides, :close_fun, &:ssh.close/1),
|
||||
ssh_dir_fun: Keyword.get(overrides, :ssh_dir_fun, &BDS.Server.ssh_dir/0)
|
||||
}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:connect, target}, _from, state) do
|
||||
state = close_current(state)
|
||||
|
||||
with {:ok, parsed} <- parse_target(target),
|
||||
{:ok, conn} <- do_connect(state, parsed),
|
||||
{:ok, local_port} <- do_tunnel(state, conn) do
|
||||
url = "http://127.0.0.1:#{local_port}/"
|
||||
|
||||
{:reply, {:ok, url},
|
||||
%{
|
||||
state
|
||||
| conn: conn,
|
||||
monitor: Process.monitor(conn),
|
||||
target: String.trim(target),
|
||||
url: url
|
||||
}}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.warning("Remote connection to #{inspect(target)} failed: #{inspect(reason)}")
|
||||
{:reply, error, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call(:disconnect, _from, state) do
|
||||
{:reply, :ok, close_current(state)}
|
||||
end
|
||||
|
||||
def handle_call(:status, _from, %{conn: nil} = state) do
|
||||
{:reply, :disconnected, state}
|
||||
end
|
||||
|
||||
def handle_call(:status, _from, state) do
|
||||
{:reply, {:connected, state.target, state.url}, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:DOWN, monitor, :process, _pid, reason}, %{monitor: monitor} = state) do
|
||||
Logger.warning("Remote SSH connection lost: #{inspect(reason)}")
|
||||
{:noreply, %{state | conn: nil, monitor: nil, target: nil, url: nil}}
|
||||
end
|
||||
|
||||
def handle_info(_message, state), do: {:noreply, state}
|
||||
|
||||
## Internal helpers
|
||||
|
||||
defp do_connect(state, %{user: user, host: host, port: port}) do
|
||||
ssh_dir = BDS.Server.ensure_ssh_dir!(state.ssh_dir_fun.())
|
||||
|
||||
state.connect_fun.(String.to_charlist(host), port,
|
||||
user: String.to_charlist(user),
|
||||
user_dir: String.to_charlist(ssh_dir),
|
||||
auth_methods: ~c"publickey",
|
||||
# First connect records the host key in <ssh_dir>/known_hosts;
|
||||
# later connects verify against it (trust-on-first-use).
|
||||
silently_accept_hosts: true,
|
||||
connect_timeout: @tunnel_timeout_ms
|
||||
)
|
||||
end
|
||||
|
||||
defp do_tunnel(state, conn) do
|
||||
remote_port = Application.get_env(:bds, :desktop)[:port] || 4010
|
||||
|
||||
state.tunnel_fun.(conn, ~c"127.0.0.1", 0, ~c"127.0.0.1", remote_port, @tunnel_timeout_ms)
|
||||
end
|
||||
|
||||
defp close_current(%{conn: nil} = state), do: state
|
||||
|
||||
defp close_current(state) do
|
||||
if state.monitor, do: Process.demonitor(state.monitor, [:flush])
|
||||
_ = state.close_fun.(state.conn)
|
||||
%{state | conn: nil, monitor: nil, target: nil, url: nil}
|
||||
end
|
||||
end
|
||||
@@ -91,14 +91,16 @@ defmodule BDS.Desktop.ShellData do
|
||||
end
|
||||
end
|
||||
|
||||
def editor_meta(task_status) do
|
||||
# Accepts the caller's already-resolved locale so hot render paths never
|
||||
# re-read the ui.language setting from the database.
|
||||
def editor_meta(task_status, ui_language \\ nil) do
|
||||
[
|
||||
%{
|
||||
label: dgettext("ui", "Status"),
|
||||
value: task_status.running_task_message || dgettext("ui", "Idle")
|
||||
},
|
||||
%{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")},
|
||||
%{label: dgettext("ui", "Main Language"), value: ui_language()}
|
||||
%{label: dgettext("ui", "Main Language"), value: ui_language || ui_language()}
|
||||
]
|
||||
end
|
||||
|
||||
@@ -107,7 +109,7 @@ defmodule BDS.Desktop.ShellData do
|
||||
post_count: dashboard.post_stats.total_posts,
|
||||
media_count: dashboard.media_stats.media_count,
|
||||
theme_badge: "desktop-shell",
|
||||
ui_language: Keyword.get(opts, :ui_language, ui_language()),
|
||||
ui_language: Keyword.get_lazy(opts, :ui_language, &ui_language/0),
|
||||
offline_mode: Keyword.get(opts, :offline_mode, true),
|
||||
running_task_message: task_status.running_task_message,
|
||||
running_task_overflow: task_status.running_task_overflow,
|
||||
|
||||
@@ -6,7 +6,6 @@ defmodule BDS.Desktop.ShellLive do
|
||||
import Phoenix.HTML
|
||||
|
||||
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
|
||||
alias BDS.CliSync.Watcher
|
||||
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData, UILocale}
|
||||
|
||||
alias BDS.Desktop.ShellLive.{
|
||||
@@ -109,7 +108,9 @@ defmodule BDS.Desktop.ShellLive do
|
||||
:quit,
|
||||
:view_on_github,
|
||||
:report_issue,
|
||||
:about
|
||||
:about,
|
||||
:connect_server,
|
||||
:disconnect_server
|
||||
])
|
||||
@runtime_menu_actions MapSet.new([
|
||||
:undo,
|
||||
@@ -151,7 +152,9 @@ defmodule BDS.Desktop.ShellLive do
|
||||
connected = connected?(socket)
|
||||
|
||||
if connected do
|
||||
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
|
||||
# Entity + settings events from every source: in-app contexts, other
|
||||
# connected clients (GUI or TUI), and the CLI sync watcher.
|
||||
:ok = BDS.Events.subscribe()
|
||||
Process.send_after(self(), :refresh_task_status, @refresh_interval)
|
||||
end
|
||||
|
||||
@@ -736,6 +739,14 @@ defmodule BDS.Desktop.ShellLive do
|
||||
{:noreply, assign(socket, :shell_overlay, overlay)}
|
||||
end
|
||||
|
||||
# The UI language is a server-side setting shared by all clients; when any
|
||||
# client (or script) changes it, every shell re-renders in the new locale.
|
||||
def handle_info({:settings_changed, "ui.language"}, socket) do
|
||||
{:noreply, set_page_language(socket, ShellData.ui_language())}
|
||||
end
|
||||
|
||||
def handle_info({:settings_changed, _key}, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_info(message, socket) do
|
||||
Bridges.handle_info(message, socket, bridges_callbacks())
|
||||
end
|
||||
@@ -981,6 +992,9 @@ defmodule BDS.Desktop.ShellLive do
|
||||
socket
|
||||
else
|
||||
UILocale.put(normalized)
|
||||
# Persist server-side so every client renders the same language; the
|
||||
# resulting settings_changed broadcast is a no-op for this socket.
|
||||
_ = BDS.Settings.put_global_setting("ui.language", normalized)
|
||||
|
||||
socket
|
||||
|> assign(:page_language, normalized)
|
||||
@@ -1079,6 +1093,34 @@ defmodule BDS.Desktop.ShellLive do
|
||||
socket
|
||||
end
|
||||
|
||||
defp handle_socket_menu_action(socket, :connect_server) do
|
||||
prompt = dgettext("ui", "Server address (user@host or user@host:port), public-key auth:")
|
||||
|
||||
with {:ok, target} <- BDS.Desktop.Dialogs.prompt_text(prompt, "user@host"),
|
||||
{:ok, url} <- BDS.Desktop.RemoteConnection.connect(target) do
|
||||
Desktop.Window.show(BDS.Desktop.MainWindow.window_id(), url)
|
||||
socket
|
||||
else
|
||||
:cancel ->
|
||||
socket
|
||||
|
||||
{:error, reason} ->
|
||||
append_output_entry(
|
||||
socket,
|
||||
dgettext("ui", "Connect to Server"),
|
||||
BDS.Desktop.RemoteConnection.format_reason(reason),
|
||||
nil,
|
||||
"error"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_socket_menu_action(socket, :disconnect_server) do
|
||||
:ok = BDS.Desktop.RemoteConnection.disconnect()
|
||||
Desktop.Window.show(BDS.Desktop.MainWindow.window_id(), BDS.Desktop.url())
|
||||
socket
|
||||
end
|
||||
|
||||
defp handle_socket_menu_action(socket, :about) do
|
||||
append_output_entry(
|
||||
socket,
|
||||
|
||||
@@ -266,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
||||
|
||||
socket
|
||||
|> assign(:task_status, task_status)
|
||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
||||
|> assign(:editor_meta, ShellData.editor_meta(task_status, socket.assigns.page_language))
|
||||
|> assign(
|
||||
:status,
|
||||
ShellData.status_bar(
|
||||
|
||||
@@ -6,11 +6,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
alias BDS.{AI, Embeddings, Metadata, Posts, Preview, Tasks}
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.Desktop.ShellLive.{EditorImageDrop, Notify}
|
||||
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata}
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Tags
|
||||
alias BDS.UI.PostEditor.{Draft, ListValues, Persistence}
|
||||
|
||||
import DraftManagement,
|
||||
import Draft,
|
||||
only: [
|
||||
editing_canonical_language?: 3,
|
||||
normalize_language: 2,
|
||||
@@ -48,7 +48,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
import PostMetadata,
|
||||
import BDS.UI.PostEditor.Metadata,
|
||||
only: [
|
||||
blank?: 1,
|
||||
blank_to_nil: 1,
|
||||
|
||||
@@ -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
|
||||
@@ -38,7 +38,7 @@ defmodule BDS.Desktop.ShellLive.SocketState do
|
||||
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|
||||
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
||||
|> assign(:current_tab, current_tab)
|
||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
||||
|> assign(:editor_meta, ShellData.editor_meta(task_status, page_language))
|
||||
|> assign(
|
||||
:status,
|
||||
ShellData.status_bar(workbench, task_status, dashboard,
|
||||
|
||||
58
lib/bds/events.ex
Normal file
58
lib/bds/events.ex
Normal file
@@ -0,0 +1,58 @@
|
||||
defmodule BDS.Events do
|
||||
@moduledoc """
|
||||
Domain event bus (issue #26, phase 2).
|
||||
|
||||
Contexts broadcast entity mutations on the same `"entity:changed"` topic
|
||||
(and payload shape) that `BDS.CliSync.Watcher` already uses for external
|
||||
CLI writes, so every connected client — LiveView shells and TUI sessions
|
||||
alike — stays synchronized through one subscription regardless of where
|
||||
a change originated.
|
||||
"""
|
||||
|
||||
@entity_topic "entity:changed"
|
||||
@settings_topic "settings:changed"
|
||||
|
||||
@type action :: :created | :updated | :deleted
|
||||
|
||||
@doc "Subscribe the calling process to entity and settings changes."
|
||||
@spec subscribe() :: :ok
|
||||
def subscribe do
|
||||
:ok = Phoenix.PubSub.subscribe(BDS.PubSub, @entity_topic)
|
||||
:ok = Phoenix.PubSub.subscribe(BDS.PubSub, @settings_topic)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcast an entity mutation as `{:entity_changed, %{entity: entity,
|
||||
entity_id: id, action: action}}` — the same message the CLI sync watcher
|
||||
emits, so subscribers handle both through one code path.
|
||||
"""
|
||||
@spec entity_changed(String.t(), String.t(), action()) :: :ok
|
||||
def entity_changed(entity, entity_id, action)
|
||||
when is_binary(entity) and is_binary(entity_id) and
|
||||
action in [:created, :updated, :deleted] do
|
||||
Phoenix.PubSub.broadcast(
|
||||
BDS.PubSub,
|
||||
@entity_topic,
|
||||
{:entity_changed, %{entity: entity, entity_id: entity_id, action: action}}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Pipe-friendly broadcast: on `{:ok, %{id: id}}` broadcasts `action` for
|
||||
`entity` and returns the result unchanged; any other result passes
|
||||
through untouched.
|
||||
"""
|
||||
@spec broadcast_result(term(), String.t(), action()) :: term()
|
||||
def broadcast_result({:ok, %{id: id}} = result, entity, action) when is_binary(id) do
|
||||
:ok = entity_changed(entity, id, action)
|
||||
result
|
||||
end
|
||||
|
||||
def broadcast_result(result, _entity, _action), do: result
|
||||
|
||||
@doc "Broadcast a global setting change as `{:settings_changed, key}`."
|
||||
@spec settings_changed(String.t()) :: :ok
|
||||
def settings_changed(key) when is_binary(key) do
|
||||
Phoenix.PubSub.broadcast(BDS.PubSub, @settings_topic, {:settings_changed, key})
|
||||
end
|
||||
end
|
||||
@@ -62,10 +62,23 @@ defmodule BDS.I18n do
|
||||
def resolve_ui_locale(locale), do: resolve_supported_locale(locale) || @default_language
|
||||
|
||||
def current_ui_locale do
|
||||
(System.get_env("LC_ALL") || System.get_env("LC_MESSAGES") || System.get_env("LANG"))
|
||||
(stored_ui_locale() || System.get_env("LC_ALL") || System.get_env("LC_MESSAGES") ||
|
||||
System.get_env("LANG"))
|
||||
|> resolve_ui_locale()
|
||||
end
|
||||
|
||||
# The UI language is a server-side setting shared by all connected clients
|
||||
# (issue #26); the OS locale is only the fallback for a fresh install.
|
||||
defp stored_ui_locale do
|
||||
with true <- BDS.Repo.ready?(),
|
||||
value when is_binary(value) and value != "" <-
|
||||
BDS.Settings.get_global_setting("ui.language") do
|
||||
value
|
||||
else
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_render_locale(language), do: resolve_supported_locale(language) || @default_language
|
||||
|
||||
def format_locale(language) do
|
||||
|
||||
@@ -34,6 +34,7 @@ defmodule BDS.Media do
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.Events
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Media.Translation
|
||||
alias BDS.Persistence
|
||||
@@ -121,6 +122,7 @@ defmodule BDS.Media do
|
||||
log_sidecar_error(write_sidecar(project, media), media.id)
|
||||
log_thumbnail_error(ensure_thumbnails(project, media), media.id)
|
||||
:ok = Search.sync_media(media)
|
||||
:ok = Events.entity_changed("media", media.id, :created)
|
||||
{:ok, media}
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -162,6 +164,7 @@ defmodule BDS.Media do
|
||||
{:ok, updated_media} ->
|
||||
log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
|
||||
:ok = Search.sync_media(updated_media)
|
||||
:ok = Events.entity_changed("media", updated_media.id, :updated)
|
||||
{:ok, updated_media}
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -211,6 +214,7 @@ defmodule BDS.Media do
|
||||
end)
|
||||
|
||||
Search.delete_media(media.id)
|
||||
:ok = Events.entity_changed("media", media.id, :deleted)
|
||||
{:ok, :deleted}
|
||||
|
||||
{:error, reason} ->
|
||||
|
||||
@@ -12,6 +12,7 @@ defmodule BDS.Posts do
|
||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||
|
||||
alias BDS.Embeddings
|
||||
alias BDS.Events
|
||||
alias BDS.Media
|
||||
alias BDS.Persistence
|
||||
alias BDS.PostLinks
|
||||
@@ -106,6 +107,7 @@ defmodule BDS.Posts do
|
||||
{:ok, post} ->
|
||||
:ok = Embeddings.sync_post(post)
|
||||
:ok = Search.sync_post(post)
|
||||
:ok = Events.entity_changed("post", post.id, :created)
|
||||
{:ok, post}
|
||||
|
||||
error ->
|
||||
@@ -151,6 +153,7 @@ defmodule BDS.Posts do
|
||||
:ok = AutoTranslation.maybe_schedule(updated_post)
|
||||
end
|
||||
|
||||
:ok = Events.entity_changed("post", updated_post.id, :updated)
|
||||
{:ok, updated_post}
|
||||
|
||||
error ->
|
||||
@@ -202,6 +205,7 @@ defmodule BDS.Posts do
|
||||
:ok = Translations.publish_post_translations(updated_post)
|
||||
:ok = PostLinks.sync_post_links(updated_post)
|
||||
:ok = Search.sync_post(updated_post)
|
||||
:ok = Events.entity_changed("post", updated_post.id, :updated)
|
||||
{:ok, updated_post}
|
||||
|
||||
error ->
|
||||
@@ -335,6 +339,7 @@ defmodule BDS.Posts do
|
||||
PostLinks.delete_post_links(deleted_post.id)
|
||||
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
|
||||
Search.delete_post(deleted_post.id)
|
||||
:ok = Events.entity_changed("post", deleted_post.id, :deleted)
|
||||
{:ok, :deleted}
|
||||
|
||||
{:error, changeset} ->
|
||||
|
||||
@@ -38,7 +38,8 @@ defmodule BDS.Scripting.ApiDocs do
|
||||
%{
|
||||
module: "app",
|
||||
name: "get_system_language",
|
||||
description: "Return the current UI locale.",
|
||||
description:
|
||||
"Return the current UI locale (the server-side UI language setting, falling back to the OS locale when unset).",
|
||||
params: [],
|
||||
returns: "string | nil"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Scripts do
|
||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||
|
||||
alias BDS.DocumentFields
|
||||
alias BDS.Events
|
||||
alias BDS.Frontmatter
|
||||
alias BDS.Persistence
|
||||
alias BDS.ProgressReporter
|
||||
@@ -40,6 +41,7 @@ defmodule BDS.Scripts do
|
||||
updated_at: now
|
||||
})
|
||||
|> Repo.insert()
|
||||
|> Events.broadcast_result("script", :created)
|
||||
end
|
||||
|
||||
@spec create_and_publish_script(attrs()) :: script_result()
|
||||
@@ -84,6 +86,7 @@ defmodule BDS.Scripts do
|
||||
serialize_script_file(script, content)
|
||||
)
|
||||
|
||||
:ok = Events.entity_changed("script", script.id, :created)
|
||||
{:ok, script}
|
||||
end
|
||||
|
||||
@@ -132,6 +135,7 @@ defmodule BDS.Scripts do
|
||||
updated_at: updated_at
|
||||
})
|
||||
|> Repo.update()
|
||||
|> Events.broadcast_result("script", :updated)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:invalid_script, reason}}
|
||||
@@ -176,6 +180,7 @@ defmodule BDS.Scripts do
|
||||
script
|
||||
|> Script.changeset(updates)
|
||||
|> Repo.update()
|
||||
|> Events.broadcast_result("script", :updated)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -189,6 +194,7 @@ defmodule BDS.Scripts do
|
||||
case Repo.delete(script) do
|
||||
{:ok, deleted_script} ->
|
||||
delete_file_if_present(deleted_script.project_id, deleted_script.file_path)
|
||||
:ok = Events.entity_changed("script", deleted_script.id, :deleted)
|
||||
{:ok, :deleted}
|
||||
|
||||
{:error, changeset} ->
|
||||
|
||||
111
lib/bds/server.ex
Normal file
111
lib/bds/server.ex
Normal 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
|
||||
@@ -27,8 +27,12 @@ defmodule BDS.Settings do
|
||||
})
|
||||
|> Repo.insert_or_update()
|
||||
|> case do
|
||||
{:ok, _setting} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
{:ok, _setting} ->
|
||||
:ok = BDS.Events.settings_changed(key)
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ defmodule BDS.Tags do
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.Events
|
||||
alias BDS.MapUtils
|
||||
alias BDS.Persistence
|
||||
alias BDS.Posts
|
||||
@@ -40,6 +41,7 @@ defmodule BDS.Tags do
|
||||
|> case do
|
||||
{:ok, tag} ->
|
||||
with :ok <- write_tags_json(project_id) do
|
||||
:ok = Events.entity_changed("tag", tag.id, :created)
|
||||
{:ok, tag}
|
||||
end
|
||||
|
||||
@@ -130,6 +132,7 @@ defmodule BDS.Tags do
|
||||
|> case do
|
||||
{:ok, updated_tag} ->
|
||||
with :ok <- write_tags_json(updated_tag.project_id) do
|
||||
:ok = Events.entity_changed("tag", updated_tag.id, :updated)
|
||||
{:ok, updated_tag}
|
||||
end
|
||||
|
||||
@@ -165,6 +168,7 @@ defmodule BDS.Tags do
|
||||
{:ok, post_ids} ->
|
||||
with :ok <- rewrite_published_posts(post_ids),
|
||||
:ok <- write_tags_json(tag.project_id) do
|
||||
:ok = Events.entity_changed("tag", tag.id, :deleted)
|
||||
{:ok, :deleted}
|
||||
end
|
||||
|
||||
@@ -205,6 +209,7 @@ defmodule BDS.Tags do
|
||||
{:ok, {updated_tag, post_ids}} ->
|
||||
with :ok <- rewrite_published_posts(post_ids),
|
||||
:ok <- write_tags_json(tag.project_id) do
|
||||
:ok = Events.entity_changed("tag", updated_tag.id, :updated)
|
||||
{:ok, updated_tag}
|
||||
end
|
||||
|
||||
@@ -250,6 +255,8 @@ defmodule BDS.Tags do
|
||||
{:ok, post_ids} ->
|
||||
with :ok <- rewrite_published_posts(post_ids),
|
||||
:ok <- write_tags_json(target_tag.project_id) do
|
||||
Enum.each(source_tags, &Events.entity_changed("tag", &1.id, :deleted))
|
||||
:ok = Events.entity_changed("tag", target_tag.id, :updated)
|
||||
{:ok, :merged}
|
||||
end
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ defmodule BDS.Templates do
|
||||
alias BDS.Projects
|
||||
alias BDS.Repo
|
||||
alias BDS.Slug
|
||||
alias BDS.Events
|
||||
alias BDS.Tags
|
||||
alias BDS.Templates.Template
|
||||
|
||||
@@ -58,6 +59,7 @@ defmodule BDS.Templates do
|
||||
serialize_template_file(template, template.content || "")
|
||||
)
|
||||
|
||||
:ok = Events.entity_changed("template", template.id, :created)
|
||||
{:ok, template}
|
||||
end
|
||||
end
|
||||
@@ -102,6 +104,7 @@ defmodule BDS.Templates do
|
||||
updated_at: updated_at
|
||||
})
|
||||
|> Repo.update()
|
||||
|> Events.broadcast_result("template", :updated)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:invalid_liquid, reason}}
|
||||
@@ -149,6 +152,7 @@ defmodule BDS.Templates do
|
||||
serialize_template_file(template, content)
|
||||
)
|
||||
|
||||
:ok = Events.entity_changed("template", template.id, :created)
|
||||
{:ok, template}
|
||||
end
|
||||
|
||||
@@ -183,6 +187,7 @@ defmodule BDS.Templates do
|
||||
affected_posts,
|
||||
slug_changed?
|
||||
) do
|
||||
:ok = Events.entity_changed("template", updated_template.id, :updated)
|
||||
{:ok, updated_template}
|
||||
end
|
||||
end
|
||||
@@ -299,6 +304,7 @@ defmodule BDS.Templates do
|
||||
case Repo.delete(template) do
|
||||
{:ok, deleted_template} ->
|
||||
delete_file_if_present(deleted_template.project_id, deleted_template.file_path)
|
||||
:ok = Events.entity_changed("template", deleted_template.id, :deleted)
|
||||
{:ok, :deleted}
|
||||
|
||||
{:error, changeset} ->
|
||||
|
||||
1064
lib/bds/tui.ex
Normal file
1064
lib/bds/tui.ex
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,9 @@ defmodule BDS.UI.MenuBar do
|
||||
%{separator: true},
|
||||
%{id: :open_in_browser},
|
||||
%{separator: true},
|
||||
%{id: :connect_server},
|
||||
%{id: :disconnect_server},
|
||||
%{separator: true},
|
||||
%{id: :open_data_folder},
|
||||
%{separator: true},
|
||||
%{id: :close_tab},
|
||||
|
||||
107
lib/bds/ui/post_editor/draft.ex
Normal file
107
lib/bds/ui/post_editor/draft.ex
Normal file
@@ -0,0 +1,107 @@
|
||||
defmodule BDS.UI.PostEditor.Draft do
|
||||
@moduledoc """
|
||||
Pure draft/form logic for the post editor (issue #26, phase 3).
|
||||
|
||||
Shared by the LiveView shell and the TUI: builds the persisted form for a
|
||||
post (canonical language or translation), normalizes editor params, and
|
||||
provides the small pure helpers both renderers need. No socket, no
|
||||
process state — callers own the draft maps.
|
||||
"""
|
||||
|
||||
alias BDS.Posts
|
||||
alias BDS.Posts.{Post, Translation}
|
||||
alias BDS.UI.PostEditor.Metadata
|
||||
|
||||
def normalize_mode(mode) when mode in [:markdown, :preview], do: mode
|
||||
def normalize_mode("visual"), do: :markdown
|
||||
def normalize_mode("preview"), do: :preview
|
||||
def normalize_mode(_mode), do: :markdown
|
||||
|
||||
def normalize_language(value, fallback) do
|
||||
case value |> to_string() |> String.trim() do
|
||||
"" -> fallback
|
||||
normalized -> String.downcase(normalized)
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_params(params, current_language, next_language) do
|
||||
%{
|
||||
"title" => Map.get(params, "title", ""),
|
||||
"excerpt" => Map.get(params, "excerpt", ""),
|
||||
"content" => Map.get(params, "content", ""),
|
||||
"tags" => Map.get(params, "tags", ""),
|
||||
"categories" => Map.get(params, "categories", ""),
|
||||
"author" => Map.get(params, "author", ""),
|
||||
"language" =>
|
||||
if(current_language == next_language,
|
||||
do: normalize_language(Map.get(params, "language"), current_language),
|
||||
else: next_language
|
||||
),
|
||||
"do_not_translate" => truthy?(Map.get(params, "do_not_translate")),
|
||||
"template_slug" => Map.get(params, "template_slug", "")
|
||||
}
|
||||
end
|
||||
|
||||
def persisted_form(%Post{} = post, metadata, active_language) do
|
||||
persisted_form(post, metadata, active_language, Metadata.translations(post.id))
|
||||
end
|
||||
|
||||
def persisted_form(post, metadata, active_language, translations) do
|
||||
canonical_language = Metadata.canonical_language(post, metadata)
|
||||
translation = Map.get(translations, active_language)
|
||||
|
||||
if active_language == canonical_language do
|
||||
%{
|
||||
"title" => post.title || "",
|
||||
"excerpt" => post.excerpt || "",
|
||||
"content" => Posts.editor_body(post),
|
||||
"tags" => Enum.join(post.tags || [], ", "),
|
||||
"categories" => Enum.join(post.categories || [], ", "),
|
||||
"author" => post.author || metadata.default_author || "",
|
||||
"language" => canonical_language,
|
||||
"do_not_translate" => post.do_not_translate || false,
|
||||
"template_slug" => post.template_slug || ""
|
||||
}
|
||||
else
|
||||
%{
|
||||
"title" => (translation && translation.title) || "",
|
||||
"excerpt" => (translation && translation.excerpt) || "",
|
||||
"content" => if(translation, do: Posts.editor_body(translation), else: ""),
|
||||
"tags" => Enum.join(post.tags || [], ", "),
|
||||
"categories" => Enum.join(post.categories || [], ", "),
|
||||
"author" => post.author || metadata.default_author || "",
|
||||
"language" => active_language,
|
||||
"do_not_translate" => post.do_not_translate || false,
|
||||
"template_slug" => post.template_slug || ""
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def save_state_for_action(:publish), do: :published
|
||||
def save_state_for_action(_action), do: :saved
|
||||
|
||||
def record_title(%Translation{title: title}, post),
|
||||
do: blank_to_nil(title) || post.title || post.slug || post.id
|
||||
|
||||
def record_title(%Post{title: title, slug: slug, id: id}, _post),
|
||||
do: blank_to_nil(title) || blank_to_nil(slug) || id
|
||||
|
||||
def record_status(%Translation{status: status}), do: status || :draft
|
||||
def record_status(%Post{status: status}), do: status || :draft
|
||||
|
||||
def editing_canonical_language?(translations, active_language, canonical_language) do
|
||||
active_language == canonical_language or not Map.has_key?(translations, active_language)
|
||||
end
|
||||
|
||||
defp truthy?(value), do: BDS.Values.truthy?(value)
|
||||
|
||||
defp blank_to_nil(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> String.trim()
|
||||
|> case do
|
||||
"" -> nil
|
||||
trimmed -> trimmed
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|
||||
defmodule BDS.UI.PostEditor.ListValues do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.{Metadata, Tags}
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|
||||
defmodule BDS.UI.PostEditor.Metadata do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
@@ -20,7 +20,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|
||||
|
||||
@spec canonical_language(term(), term()) :: term()
|
||||
def canonical_language(post, metadata) do
|
||||
BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language(
|
||||
BDS.UI.PostEditor.Draft.normalize_language(
|
||||
post.language,
|
||||
metadata.main_language || "en"
|
||||
)
|
||||
@@ -1,17 +1,21 @@
|
||||
defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
|
||||
@moduledoc false
|
||||
defmodule BDS.UI.PostEditor.Persistence do
|
||||
@moduledoc """
|
||||
Save/publish/discard workflow for post drafts, shared by the LiveView
|
||||
shell and the TUI (issue #26, phase 3). Routes canonical-language edits
|
||||
to the post itself and other languages to translations.
|
||||
"""
|
||||
|
||||
alias BDS.Posts
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, PostMetadata}
|
||||
alias BDS.UI.PostEditor.{Draft, Metadata}
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
@spec persist(term(), term(), term(), term(), term()) :: term()
|
||||
def persist(%Post{} = post, draft, active_language, metadata, action) do
|
||||
canonical_language = PostMetadata.canonical_language(post, metadata)
|
||||
translations = PostMetadata.translations(post.id)
|
||||
canonical_language = Metadata.canonical_language(post, metadata)
|
||||
translations = Metadata.translations(post.id)
|
||||
|
||||
if DraftManagement.editing_canonical_language?(
|
||||
if Draft.editing_canonical_language?(
|
||||
translations,
|
||||
active_language,
|
||||
canonical_language
|
||||
@@ -28,11 +32,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
|
||||
|
||||
@spec discard(term(), term(), term()) :: term()
|
||||
def discard(%Post{} = post, active_language, metadata) do
|
||||
canonical_language = PostMetadata.canonical_language(post, metadata)
|
||||
current_translations = PostMetadata.translations(post.id)
|
||||
canonical_language = Metadata.canonical_language(post, metadata)
|
||||
current_translations = Metadata.translations(post.id)
|
||||
|
||||
cond do
|
||||
not DraftManagement.editing_canonical_language?(
|
||||
not Draft.editing_canonical_language?(
|
||||
current_translations,
|
||||
active_language,
|
||||
canonical_language
|
||||
1
mix.exs
1
mix.exs
@@ -40,6 +40,7 @@ defmodule BDS.MixProject do
|
||||
{:live_toast, "~> 0.8.0"},
|
||||
{:saxy, "~> 1.4"},
|
||||
{:desktop, "~> 1.5"},
|
||||
{:ex_ratatui, "~> 0.11"},
|
||||
{:image, "~> 0.67"},
|
||||
{:nx, "~> 0.10"},
|
||||
{:exla, "~> 0.10"},
|
||||
|
||||
1
mix.lock
1
mix.lock
@@ -25,6 +25,7 @@
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"},
|
||||
"ex_ratatui": {:hex, :ex_ratatui, "0.11.1", "f3cc5c9827d957bc326d3b3b48338a927716fedc2575311d65a3af0dd03ed951", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b37b02b33e1e8f49874497d7b7f8872417b3807ec77d4bc1526c07c05c7e4cc9"},
|
||||
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
|
||||
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
|
||||
"exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ msgstr ""
|
||||
msgid "%{count} mapped"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:171
|
||||
#: lib/bds/desktop/shell_data.ex:173
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} post"
|
||||
msgid_plural "%{count} posts"
|
||||
@@ -71,8 +71,8 @@ msgid "AI Assistant"
|
||||
msgstr ""
|
||||
|
||||
#: 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:208
|
||||
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:193
|
||||
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:211
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "AI Settings"
|
||||
msgstr ""
|
||||
@@ -81,6 +81,11 @@ msgstr ""
|
||||
#: 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_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
|
||||
msgid "AI Suggestions"
|
||||
msgstr ""
|
||||
@@ -105,12 +110,12 @@ msgstr ""
|
||||
msgid "API"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:234
|
||||
#: lib/bds/desktop/menu_bar.ex:265
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "API Documentation"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:232
|
||||
#: lib/bds/desktop/menu_bar.ex:263
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "About"
|
||||
msgstr ""
|
||||
@@ -301,7 +306,7 @@ msgstr ""
|
||||
msgid "Validation apply prepared"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:164
|
||||
#: lib/bds/desktop/shell_data.ex:166
|
||||
#: lib/bds/ui/sidebar.ex:331
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Archived"
|
||||
@@ -336,7 +341,7 @@ msgstr ""
|
||||
msgid "Auto"
|
||||
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/media_editor.ex:171
|
||||
#: lib/bds/desktop/shell_live/media_editor.ex:364
|
||||
@@ -367,7 +372,7 @@ msgstr ""
|
||||
msgid "Backlinks"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:181
|
||||
#: lib/bds/desktop/menu_bar.ex:210
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Blog"
|
||||
msgstr ""
|
||||
@@ -486,7 +491,7 @@ msgstr ""
|
||||
msgid "Category name is required"
|
||||
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:233
|
||||
#: lib/bds/desktop/shell_live/chat_editor.ex:323
|
||||
@@ -544,7 +549,7 @@ msgstr ""
|
||||
msgid "Click to %{action} mapping"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:190
|
||||
#: lib/bds/desktop/menu_bar.ex:221
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Close Tab"
|
||||
msgstr ""
|
||||
@@ -567,6 +572,7 @@ msgid "Collapse unchanged diff hunks"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live/overlay_manager.ex:422
|
||||
#: lib/bds/tui.ex:1017
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Command completed"
|
||||
msgstr ""
|
||||
@@ -584,6 +590,7 @@ msgstr ""
|
||||
#: 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/template_editor_html/template_editor.html.heex:32
|
||||
#: lib/bds/tui.ex:550
|
||||
#: lib/bds/ui/sidebar.ex:764
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Content"
|
||||
@@ -594,7 +601,7 @@ msgstr ""
|
||||
msgid "Content Categories"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:195
|
||||
#: lib/bds/desktop/menu_bar.ex:226
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy"
|
||||
msgstr ""
|
||||
@@ -647,7 +654,7 @@ msgstr ""
|
||||
msgid "Created"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:194
|
||||
#: lib/bds/desktop/menu_bar.ex:225
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cut"
|
||||
msgstr ""
|
||||
@@ -713,7 +720,7 @@ msgstr ""
|
||||
msgid "Default editing mode and diff presentation"
|
||||
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/overlay_html/shell_overlay.html.heex:166
|
||||
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:116
|
||||
@@ -753,7 +760,7 @@ msgstr ""
|
||||
msgid "Delete conversation"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:65
|
||||
#: lib/bds/ui/post_editor/persistence.ex:69
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete this unpublished draft"
|
||||
msgstr ""
|
||||
@@ -810,17 +817,17 @@ msgstr ""
|
||||
msgid "Disable reasoning output for the online chat model"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:57
|
||||
#: lib/bds/ui/post_editor/persistence.ex:61
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Discard Changes"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:58
|
||||
#: lib/bds/ui/post_editor/persistence.ex:62
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Discard Draft"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:64
|
||||
#: lib/bds/ui/post_editor/persistence.ex:68
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Discard changes and restore the published version"
|
||||
msgstr ""
|
||||
@@ -856,14 +863,14 @@ msgstr ""
|
||||
msgid "Do Not Translate"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:233
|
||||
#: lib/bds/desktop/menu_bar.ex:264
|
||||
#: lib/bds/help_docs.ex:14
|
||||
#: lib/bds/ui/registry.ex:118
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Documentation"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:162
|
||||
#: lib/bds/desktop/shell_data.ex:164
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Draft"
|
||||
msgstr ""
|
||||
@@ -889,12 +896,12 @@ msgstr ""
|
||||
msgid "ETA: %{value}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:179
|
||||
#: lib/bds/desktop/menu_bar.ex:208
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:216
|
||||
#: lib/bds/desktop/menu_bar.ex:247
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Menu"
|
||||
msgstr ""
|
||||
@@ -1000,7 +1007,7 @@ msgstr ""
|
||||
msgid "Extra URLs"
|
||||
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/misc_editor_html/misc_editor.html.heex:157
|
||||
#, elixir-autogen, elixir-format
|
||||
@@ -1022,17 +1029,19 @@ msgstr ""
|
||||
msgid "Filename"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:223
|
||||
#: lib/bds/desktop/menu_bar.ex:254
|
||||
#: lib/bds/tui.ex:981
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fill Missing Translations"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:199
|
||||
#: lib/bds/desktop/menu_bar.ex:230
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Find"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:224
|
||||
#: lib/bds/desktop/menu_bar.ex:255
|
||||
#: lib/bds/tui.ex:984
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Find Duplicate Posts"
|
||||
msgstr ""
|
||||
@@ -1047,7 +1056,7 @@ msgstr ""
|
||||
msgid "Find Duplicates"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:209
|
||||
#: lib/bds/desktop/menu_bar.ex:240
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Force Reload"
|
||||
msgstr ""
|
||||
@@ -1058,12 +1067,13 @@ msgstr ""
|
||||
msgid "Gallery"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:225
|
||||
#: lib/bds/desktop/menu_bar.ex:256
|
||||
#: lib/bds/tui.ex:965
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Generate Site"
|
||||
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/ui/sidebar.ex:789
|
||||
#, elixir-autogen, elixir-format
|
||||
@@ -1076,14 +1086,14 @@ msgstr ""
|
||||
msgid "Git Diff"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:226
|
||||
#: lib/bds/desktop/shell_live.ex:768
|
||||
#: lib/bds/desktop/shell_data.ex:228
|
||||
#: lib/bds/desktop/shell_live.ex:788
|
||||
#: lib/bds/desktop/shell_live/panel_renderer.ex:171
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Git Log"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:183
|
||||
#: lib/bds/desktop/menu_bar.ex:212
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Help"
|
||||
msgstr ""
|
||||
@@ -1103,7 +1113,7 @@ msgstr ""
|
||||
msgid "Host"
|
||||
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/media_editor.ex:731
|
||||
#: lib/bds/desktop/shell_live/post_editor.ex:1038
|
||||
@@ -1170,7 +1180,7 @@ msgstr ""
|
||||
msgid "Import (new slug)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:186
|
||||
#: lib/bds/desktop/menu_bar.ex:215
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Media"
|
||||
msgstr ""
|
||||
@@ -1200,9 +1210,9 @@ msgstr ""
|
||||
msgid "Import failed: %{error}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:598
|
||||
#: lib/bds/desktop/shell_live.ex:884
|
||||
#: lib/bds/desktop/shell_live.ex:890
|
||||
#: lib/bds/desktop/shell_live.ex:608
|
||||
#: lib/bds/desktop/shell_live.ex:904
|
||||
#: lib/bds/desktop/shell_live.ex:910
|
||||
#: lib/bds/desktop/shell_live/sidebar_create.ex:47
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import media"
|
||||
@@ -1337,7 +1347,7 @@ msgstr ""
|
||||
msgid "Macros (%{count})"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Main Language"
|
||||
@@ -1372,13 +1382,16 @@ msgstr ""
|
||||
msgid "Max Posts Per Page"
|
||||
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:513
|
||||
#: lib/bds/desktop/shell_live/misc_editor.ex:735
|
||||
#: lib/bds/desktop/shell_live/misc_editor.ex:762
|
||||
#: lib/bds/desktop/shell_live/sidebar_components.ex:654
|
||||
#: 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:100
|
||||
#: lib/bds/ui/sidebar.ex:558
|
||||
@@ -1417,10 +1430,15 @@ msgstr ""
|
||||
msgid "Metadata"
|
||||
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:226
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Metadata Diff"
|
||||
@@ -1431,7 +1449,7 @@ msgstr ""
|
||||
msgid "Missing URLs"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:100
|
||||
#: lib/bds/desktop/shell_data.ex:102
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mode"
|
||||
msgstr ""
|
||||
@@ -1464,8 +1482,9 @@ msgstr ""
|
||||
msgid "New Page"
|
||||
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/tui.ex:150
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New Post"
|
||||
msgstr ""
|
||||
@@ -1644,7 +1663,7 @@ msgstr ""
|
||||
msgid "Nothing to Import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:100
|
||||
#: lib/bds/desktop/shell_data.ex:102
|
||||
#: lib/bds/desktop/shell_live/index.html.heex:503
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Offline"
|
||||
@@ -1736,7 +1755,7 @@ msgstr ""
|
||||
msgid "Open"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open Data Folder"
|
||||
@@ -1753,7 +1772,8 @@ msgstr ""
|
||||
msgid "Open Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:188
|
||||
#: lib/bds/desktop/menu_bar.ex:217
|
||||
#: lib/bds/tui.ex:986
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in Browser"
|
||||
msgstr ""
|
||||
@@ -1784,7 +1804,7 @@ msgstr ""
|
||||
msgid "Other (%{count})"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:767
|
||||
#: lib/bds/desktop/shell_live.ex:787
|
||||
#: lib/bds/desktop/shell_live/panel_renderer.ex:83
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Output"
|
||||
@@ -1832,7 +1852,7 @@ msgstr ""
|
||||
msgid "Parsing WXR file..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:196
|
||||
#: lib/bds/desktop/menu_bar.ex:227
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Paste"
|
||||
msgstr ""
|
||||
@@ -1866,7 +1886,7 @@ msgstr ""
|
||||
msgid "Post"
|
||||
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/post_editor_html/post_editor.html.heex:310
|
||||
#, elixir-autogen, elixir-format
|
||||
@@ -1898,9 +1918,11 @@ msgstr ""
|
||||
msgid "Post saved"
|
||||
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:761
|
||||
#: lib/bds/tui.ex:835
|
||||
#: lib/bds/tui.ex:846
|
||||
#: lib/bds/ui/registry.ex:14
|
||||
#: lib/bds/ui/sidebar.ex:271
|
||||
#, elixir-autogen, elixir-format
|
||||
@@ -1912,7 +1934,7 @@ msgstr ""
|
||||
msgid "Posts (%{count})"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:201
|
||||
#: lib/bds/desktop/menu_bar.ex:232
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Preferences"
|
||||
msgstr ""
|
||||
@@ -1928,7 +1950,7 @@ msgstr ""
|
||||
msgid "Preview Mode"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:215
|
||||
#: lib/bds/desktop/menu_bar.ex:246
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Preview Post"
|
||||
msgstr ""
|
||||
@@ -1980,12 +2002,12 @@ msgstr ""
|
||||
msgid "Publish"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:214
|
||||
#: lib/bds/desktop/menu_bar.ex:245
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Publish Selected"
|
||||
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_html/post_editor.html.heex:472
|
||||
#: lib/bds/ui/sidebar.ex:324
|
||||
@@ -2013,7 +2035,7 @@ msgstr ""
|
||||
msgid "Quick Actions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:191
|
||||
#: lib/bds/desktop/menu_bar.ex:222
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Quit"
|
||||
msgstr ""
|
||||
@@ -2023,13 +2045,15 @@ msgstr ""
|
||||
msgid "Ready to import:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:217
|
||||
#: lib/bds/desktop/menu_bar.ex:248
|
||||
#: lib/bds/tui.ex:966
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Rebuild Database"
|
||||
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/tui.ex:970
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Rebuild Embedding Index"
|
||||
msgstr ""
|
||||
@@ -2059,7 +2083,7 @@ msgstr ""
|
||||
msgid "Rebuild filesystem-backed records and thumbnails"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:193
|
||||
#: lib/bds/desktop/menu_bar.ex:224
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Redo"
|
||||
msgstr ""
|
||||
@@ -2086,7 +2110,8 @@ msgstr ""
|
||||
msgid "Refresh Translation"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:221
|
||||
#: lib/bds/desktop/menu_bar.ex:252
|
||||
#: lib/bds/tui.ex:973
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Regenerate Calendar"
|
||||
msgstr ""
|
||||
@@ -2096,12 +2121,13 @@ msgstr ""
|
||||
msgid "Regenerate Missing Thumbnails"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:218
|
||||
#: lib/bds/desktop/menu_bar.ex:249
|
||||
#: lib/bds/tui.ex:967
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reindex Text"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:208
|
||||
#: lib/bds/desktop/menu_bar.ex:239
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reload"
|
||||
msgstr ""
|
||||
@@ -2132,7 +2158,7 @@ msgstr ""
|
||||
msgid "Render in Lists"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:200
|
||||
#: lib/bds/desktop/menu_bar.ex:231
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Replace"
|
||||
msgstr ""
|
||||
@@ -2149,12 +2175,12 @@ msgstr ""
|
||||
msgid "Replace Media File"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:236
|
||||
#: lib/bds/desktop/menu_bar.ex:267
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Report Issue"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:210
|
||||
#: lib/bds/desktop/menu_bar.ex:241
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reset Zoom"
|
||||
msgstr ""
|
||||
@@ -2209,7 +2235,7 @@ msgstr ""
|
||||
msgid "SSH Mode"
|
||||
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:299
|
||||
#: 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:219
|
||||
#: lib/bds/desktop/shell_live/script_editor.ex:234
|
||||
#: lib/bds/tui.ex:838
|
||||
#: lib/bds/ui/registry.ex:38
|
||||
#: lib/bds/ui/sidebar.ex:63
|
||||
#: lib/bds/ui/sidebar.ex:115
|
||||
@@ -2327,7 +2354,7 @@ msgstr ""
|
||||
msgid "Select & Analyze"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:198
|
||||
#: lib/bds/desktop/menu_bar.ex:229
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select All"
|
||||
msgstr ""
|
||||
@@ -2409,6 +2436,9 @@ msgid "Site"
|
||||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Site Validation"
|
||||
@@ -2456,7 +2486,7 @@ msgstr ""
|
||||
msgid "Starting..."
|
||||
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:1233
|
||||
#, 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:253
|
||||
#: 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:103
|
||||
#: lib/bds/ui/sidebar.ex:289
|
||||
@@ -2538,8 +2569,9 @@ msgstr ""
|
||||
msgid "Tags"
|
||||
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/tui.ex:403
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Tasks"
|
||||
msgstr ""
|
||||
@@ -2584,6 +2616,7 @@ msgstr ""
|
||||
#: 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:191
|
||||
#: lib/bds/tui.ex:837
|
||||
#: lib/bds/ui/registry.ex:46
|
||||
#: lib/bds/ui/sidebar.ex:71
|
||||
#: lib/bds/ui/sidebar.ex:122
|
||||
@@ -2633,12 +2666,12 @@ msgstr ""
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:206
|
||||
#: lib/bds/desktop/menu_bar.ex:237
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle Assistant Sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:207
|
||||
#: lib/bds/desktop/menu_bar.ex:238
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle Dev Tools"
|
||||
msgstr ""
|
||||
@@ -2649,17 +2682,17 @@ msgstr ""
|
||||
msgid "Toggle Filters"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:213
|
||||
#: lib/bds/desktop/menu_bar.ex:244
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle Full Screen"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:205
|
||||
#: lib/bds/desktop/menu_bar.ex:236
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle Panel"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:204
|
||||
#: lib/bds/desktop/menu_bar.ex:235
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle Sidebar"
|
||||
msgstr ""
|
||||
@@ -2755,7 +2788,7 @@ msgstr ""
|
||||
msgid "URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:192
|
||||
#: lib/bds/desktop/menu_bar.ex:223
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Undo"
|
||||
msgstr ""
|
||||
@@ -2780,7 +2813,7 @@ msgid "Unsaved"
|
||||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Untitled"
|
||||
@@ -2805,7 +2838,8 @@ msgstr ""
|
||||
msgid "Updated URLs"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:228
|
||||
#: lib/bds/desktop/menu_bar.ex:259
|
||||
#: lib/bds/tui.ex:985
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Upload Site"
|
||||
msgstr ""
|
||||
@@ -2832,22 +2866,24 @@ msgstr ""
|
||||
msgid "Validate"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:227
|
||||
#: lib/bds/desktop/menu_bar.ex:258
|
||||
#: lib/bds/tui.ex:963
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Validate Site"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:222
|
||||
#: lib/bds/desktop/menu_bar.ex:253
|
||||
#: lib/bds/tui.ex:976
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Validate Translations"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:180
|
||||
#: lib/bds/desktop/menu_bar.ex:209
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:235
|
||||
#: lib/bds/desktop/menu_bar.ex:266
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "View on GitHub"
|
||||
msgstr ""
|
||||
@@ -2891,12 +2927,12 @@ msgstr ""
|
||||
msgid "You"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:211
|
||||
#: lib/bds/desktop/menu_bar.ex:242
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Zoom In"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:212
|
||||
#: lib/bds/desktop/menu_bar.ex:243
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Zoom Out"
|
||||
msgstr ""
|
||||
@@ -3218,12 +3254,12 @@ msgstr ""
|
||||
msgid "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
|
||||
msgid "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
|
||||
msgid "Added %{title}"
|
||||
msgstr "Added %{title}"
|
||||
@@ -3243,18 +3279,18 @@ msgstr ""
|
||||
msgid "Image Import Concurrency"
|
||||
msgstr "Image Import Concurrency"
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:447
|
||||
#: lib/bds/desktop/shell_live.ex:460
|
||||
#: lib/bds/desktop/shell_live.ex:641
|
||||
#: lib/bds/desktop/shell_live.ex:673
|
||||
#: lib/bds/desktop/shell_live.ex:684
|
||||
#: lib/bds/desktop/shell_live.ex:695
|
||||
#: lib/bds/desktop/shell_live.ex:444
|
||||
#: lib/bds/desktop/shell_live.ex:457
|
||||
#: lib/bds/desktop/shell_live.ex:651
|
||||
#: lib/bds/desktop/shell_live.ex:683
|
||||
#: lib/bds/desktop/shell_live.ex:694
|
||||
#: lib/bds/desktop/shell_live.ex:705
|
||||
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:420
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "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
|
||||
msgid "Failed to process %{path}: %{reason}"
|
||||
msgstr "Failed to process %{path}: %{reason}"
|
||||
@@ -3445,12 +3481,12 @@ msgstr ""
|
||||
msgid "untracked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:792
|
||||
#: lib/bds/desktop/shell_live.ex:812
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Blogmark"
|
||||
msgstr "Blogmark"
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:835
|
||||
#: lib/bds/desktop/shell_live.ex:855
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open a project before importing a blogmark."
|
||||
msgstr ""
|
||||
@@ -3491,7 +3527,7 @@ msgstr ""
|
||||
msgid "Failed to copy bookmarklet to clipboard"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:804
|
||||
#: lib/bds/desktop/shell_live.ex:824
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The project this blogmark targets does not exist here."
|
||||
msgstr ""
|
||||
@@ -3501,7 +3537,8 @@ msgstr ""
|
||||
msgid "Suggested tags"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:226
|
||||
#: lib/bds/desktop/menu_bar.ex:257
|
||||
#: lib/bds/tui.ex:964
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Force Render Site"
|
||||
msgstr ""
|
||||
@@ -3516,22 +3553,22 @@ msgstr ""
|
||||
msgid "Rebuild Post Links"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:231
|
||||
#: lib/bds/desktop/menu_bar.ex:262
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Bring All to Front"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:229
|
||||
#: lib/bds/desktop/menu_bar.ex:260
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Minimize"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:182
|
||||
#: lib/bds/desktop/menu_bar.ex:211
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Window"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:230
|
||||
#: lib/bds/desktop/menu_bar.ex:261
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Zoom"
|
||||
msgstr ""
|
||||
@@ -3578,12 +3615,12 @@ msgstr ""
|
||||
msgid "Could not load models"
|
||||
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
|
||||
msgid "Could not save AI settings"
|
||||
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
|
||||
msgid "Loaded %{count} models."
|
||||
msgstr ""
|
||||
@@ -3593,7 +3630,209 @@ msgstr ""
|
||||
msgid "OK"
|
||||
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
|
||||
msgid "The endpoint returned no models. You can still type a model name manually."
|
||||
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
@@ -23,7 +23,7 @@ msgstr ""
|
||||
msgid "%{count} mapped"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:171
|
||||
#: lib/bds/desktop/shell_data.ex:173
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} post"
|
||||
msgid_plural "%{count} posts"
|
||||
@@ -84,8 +84,8 @@ msgid "AI Assistant"
|
||||
msgstr ""
|
||||
|
||||
#: 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:208
|
||||
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:193
|
||||
#: lib/bds/desktop/shell_live/settings_editor/ai_settings.ex:211
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "AI Settings"
|
||||
msgstr ""
|
||||
@@ -94,6 +94,11 @@ msgstr ""
|
||||
#: 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_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
|
||||
msgid "AI Suggestions"
|
||||
msgstr ""
|
||||
@@ -118,12 +123,12 @@ msgstr ""
|
||||
msgid "API"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:234
|
||||
#: lib/bds/desktop/menu_bar.ex:265
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "API Documentation"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:232
|
||||
#: lib/bds/desktop/menu_bar.ex:263
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "About"
|
||||
msgstr ""
|
||||
@@ -314,7 +319,7 @@ msgstr ""
|
||||
msgid "Validation apply prepared"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:164
|
||||
#: lib/bds/desktop/shell_data.ex:166
|
||||
#: lib/bds/ui/sidebar.ex:331
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Archived"
|
||||
@@ -349,7 +354,7 @@ msgstr ""
|
||||
msgid "Auto"
|
||||
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/media_editor.ex:171
|
||||
#: lib/bds/desktop/shell_live/media_editor.ex:364
|
||||
@@ -380,7 +385,7 @@ msgstr ""
|
||||
msgid "Backlinks"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:181
|
||||
#: lib/bds/desktop/menu_bar.ex:210
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Blog"
|
||||
msgstr ""
|
||||
@@ -499,7 +504,7 @@ msgstr ""
|
||||
msgid "Category name is required"
|
||||
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:233
|
||||
#: lib/bds/desktop/shell_live/chat_editor.ex:323
|
||||
@@ -557,7 +562,7 @@ msgstr ""
|
||||
msgid "Click to %{action} mapping"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:190
|
||||
#: lib/bds/desktop/menu_bar.ex:221
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Close Tab"
|
||||
msgstr ""
|
||||
@@ -580,6 +585,7 @@ msgid "Collapse unchanged diff hunks"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live/overlay_manager.ex:422
|
||||
#: lib/bds/tui.ex:1017
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Command completed"
|
||||
msgstr ""
|
||||
@@ -597,6 +603,7 @@ msgstr ""
|
||||
#: 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/template_editor_html/template_editor.html.heex:32
|
||||
#: lib/bds/tui.ex:550
|
||||
#: lib/bds/ui/sidebar.ex:764
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Content"
|
||||
@@ -607,7 +614,7 @@ msgstr ""
|
||||
msgid "Content Categories"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:195
|
||||
#: lib/bds/desktop/menu_bar.ex:226
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy"
|
||||
msgstr ""
|
||||
@@ -660,7 +667,7 @@ msgstr ""
|
||||
msgid "Created"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:194
|
||||
#: lib/bds/desktop/menu_bar.ex:225
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cut"
|
||||
msgstr ""
|
||||
@@ -726,7 +733,7 @@ msgstr ""
|
||||
msgid "Default editing mode and diff presentation"
|
||||
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/overlay_html/shell_overlay.html.heex:166
|
||||
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:116
|
||||
@@ -766,7 +773,7 @@ msgstr ""
|
||||
msgid "Delete conversation"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:65
|
||||
#: lib/bds/ui/post_editor/persistence.ex:69
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete this unpublished draft"
|
||||
msgstr ""
|
||||
@@ -823,17 +830,17 @@ msgstr ""
|
||||
msgid "Disable reasoning output for the online chat model"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:57
|
||||
#: lib/bds/ui/post_editor/persistence.ex:61
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Discard Changes"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:58
|
||||
#: lib/bds/ui/post_editor/persistence.ex:62
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Discard Draft"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live/post_editor/persistence.ex:64
|
||||
#: lib/bds/ui/post_editor/persistence.ex:68
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Discard changes and restore the published version"
|
||||
msgstr ""
|
||||
@@ -869,14 +876,14 @@ msgstr ""
|
||||
msgid "Do Not Translate"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:233
|
||||
#: lib/bds/desktop/menu_bar.ex:264
|
||||
#: lib/bds/help_docs.ex:14
|
||||
#: lib/bds/ui/registry.ex:118
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Documentation"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:162
|
||||
#: lib/bds/desktop/shell_data.ex:164
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Draft"
|
||||
msgstr ""
|
||||
@@ -902,12 +909,12 @@ msgstr ""
|
||||
msgid "ETA: %{value}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:179
|
||||
#: lib/bds/desktop/menu_bar.ex:208
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:216
|
||||
#: lib/bds/desktop/menu_bar.ex:247
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Menu"
|
||||
msgstr ""
|
||||
@@ -1013,7 +1020,7 @@ msgstr ""
|
||||
msgid "Extra URLs"
|
||||
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/misc_editor_html/misc_editor.html.heex:157
|
||||
#, elixir-autogen, elixir-format
|
||||
@@ -1035,17 +1042,19 @@ msgstr ""
|
||||
msgid "Filename"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:223
|
||||
#: lib/bds/desktop/menu_bar.ex:254
|
||||
#: lib/bds/tui.ex:981
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fill Missing Translations"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:199
|
||||
#: lib/bds/desktop/menu_bar.ex:230
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Find"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:224
|
||||
#: lib/bds/desktop/menu_bar.ex:255
|
||||
#: lib/bds/tui.ex:984
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Find Duplicate Posts"
|
||||
msgstr ""
|
||||
@@ -1060,7 +1069,7 @@ msgstr ""
|
||||
msgid "Find Duplicates"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:209
|
||||
#: lib/bds/desktop/menu_bar.ex:240
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Force Reload"
|
||||
msgstr ""
|
||||
@@ -1071,12 +1080,13 @@ msgstr ""
|
||||
msgid "Gallery"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:225
|
||||
#: lib/bds/desktop/menu_bar.ex:256
|
||||
#: lib/bds/tui.ex:965
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Generate Site"
|
||||
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/ui/sidebar.ex:789
|
||||
#, elixir-autogen, elixir-format
|
||||
@@ -1089,14 +1099,14 @@ msgstr ""
|
||||
msgid "Git Diff"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:226
|
||||
#: lib/bds/desktop/shell_live.ex:768
|
||||
#: lib/bds/desktop/shell_data.ex:228
|
||||
#: lib/bds/desktop/shell_live.ex:788
|
||||
#: lib/bds/desktop/shell_live/panel_renderer.ex:171
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Git Log"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:183
|
||||
#: lib/bds/desktop/menu_bar.ex:212
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Help"
|
||||
msgstr ""
|
||||
@@ -1116,7 +1126,7 @@ msgstr ""
|
||||
msgid "Host"
|
||||
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/media_editor.ex:731
|
||||
#: lib/bds/desktop/shell_live/post_editor.ex:1038
|
||||
@@ -1183,7 +1193,7 @@ msgstr ""
|
||||
msgid "Import (new slug)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:186
|
||||
#: lib/bds/desktop/menu_bar.ex:215
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Media"
|
||||
msgstr ""
|
||||
@@ -1213,9 +1223,9 @@ msgstr ""
|
||||
msgid "Import failed: %{error}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:598
|
||||
#: lib/bds/desktop/shell_live.ex:884
|
||||
#: lib/bds/desktop/shell_live.ex:890
|
||||
#: lib/bds/desktop/shell_live.ex:608
|
||||
#: lib/bds/desktop/shell_live.ex:904
|
||||
#: lib/bds/desktop/shell_live.ex:910
|
||||
#: lib/bds/desktop/shell_live/sidebar_create.ex:47
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import media"
|
||||
@@ -1350,7 +1360,7 @@ msgstr ""
|
||||
msgid "Macros (%{count})"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Main Language"
|
||||
@@ -1385,13 +1395,16 @@ msgstr ""
|
||||
msgid "Max Posts Per Page"
|
||||
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:513
|
||||
#: lib/bds/desktop/shell_live/misc_editor.ex:735
|
||||
#: lib/bds/desktop/shell_live/misc_editor.ex:762
|
||||
#: lib/bds/desktop/shell_live/sidebar_components.ex:654
|
||||
#: 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:100
|
||||
#: lib/bds/ui/sidebar.ex:558
|
||||
@@ -1430,10 +1443,15 @@ msgstr ""
|
||||
msgid "Metadata"
|
||||
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:226
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Metadata Diff"
|
||||
@@ -1444,7 +1462,7 @@ msgstr ""
|
||||
msgid "Missing URLs"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:100
|
||||
#: lib/bds/desktop/shell_data.ex:102
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mode"
|
||||
msgstr ""
|
||||
@@ -1477,8 +1495,9 @@ msgstr ""
|
||||
msgid "New Page"
|
||||
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/tui.ex:150
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New Post"
|
||||
msgstr ""
|
||||
@@ -1657,7 +1676,7 @@ msgstr ""
|
||||
msgid "Nothing to Import"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_data.ex:100
|
||||
#: lib/bds/desktop/shell_data.ex:102
|
||||
#: lib/bds/desktop/shell_live/index.html.heex:503
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Offline"
|
||||
@@ -1749,7 +1768,7 @@ msgstr ""
|
||||
msgid "Open"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open Data Folder"
|
||||
@@ -1766,7 +1785,8 @@ msgstr ""
|
||||
msgid "Open Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:188
|
||||
#: lib/bds/desktop/menu_bar.ex:217
|
||||
#: lib/bds/tui.ex:986
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in Browser"
|
||||
msgstr ""
|
||||
@@ -1797,7 +1817,7 @@ msgstr ""
|
||||
msgid "Other (%{count})"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:767
|
||||
#: lib/bds/desktop/shell_live.ex:787
|
||||
#: lib/bds/desktop/shell_live/panel_renderer.ex:83
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Output"
|
||||
@@ -1845,7 +1865,7 @@ msgstr ""
|
||||
msgid "Parsing WXR file..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:196
|
||||
#: lib/bds/desktop/menu_bar.ex:227
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Paste"
|
||||
msgstr ""
|
||||
@@ -1879,7 +1899,7 @@ msgstr ""
|
||||
msgid "Post"
|
||||
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/post_editor_html/post_editor.html.heex:310
|
||||
#, elixir-autogen, elixir-format
|
||||
@@ -1911,9 +1931,11 @@ msgstr ""
|
||||
msgid "Post saved"
|
||||
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:761
|
||||
#: lib/bds/tui.ex:835
|
||||
#: lib/bds/tui.ex:846
|
||||
#: lib/bds/ui/registry.ex:14
|
||||
#: lib/bds/ui/sidebar.ex:271
|
||||
#, elixir-autogen, elixir-format
|
||||
@@ -1925,7 +1947,7 @@ msgstr ""
|
||||
msgid "Posts (%{count})"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:201
|
||||
#: lib/bds/desktop/menu_bar.ex:232
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Preferences"
|
||||
msgstr ""
|
||||
@@ -1941,7 +1963,7 @@ msgstr ""
|
||||
msgid "Preview Mode"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:215
|
||||
#: lib/bds/desktop/menu_bar.ex:246
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Preview Post"
|
||||
msgstr ""
|
||||
@@ -1993,12 +2015,12 @@ msgstr ""
|
||||
msgid "Publish"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:214
|
||||
#: lib/bds/desktop/menu_bar.ex:245
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Publish Selected"
|
||||
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_html/post_editor.html.heex:472
|
||||
#: lib/bds/ui/sidebar.ex:324
|
||||
@@ -2026,7 +2048,7 @@ msgstr ""
|
||||
msgid "Quick Actions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:191
|
||||
#: lib/bds/desktop/menu_bar.ex:222
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Quit"
|
||||
msgstr ""
|
||||
@@ -2036,13 +2058,15 @@ msgstr ""
|
||||
msgid "Ready to import:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:217
|
||||
#: lib/bds/desktop/menu_bar.ex:248
|
||||
#: lib/bds/tui.ex:966
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Rebuild Database"
|
||||
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/tui.ex:970
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Rebuild Embedding Index"
|
||||
msgstr ""
|
||||
@@ -2072,7 +2096,7 @@ msgstr ""
|
||||
msgid "Rebuild filesystem-backed records and thumbnails"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:193
|
||||
#: lib/bds/desktop/menu_bar.ex:224
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Redo"
|
||||
msgstr ""
|
||||
@@ -2099,7 +2123,8 @@ msgstr ""
|
||||
msgid "Refresh Translation"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:221
|
||||
#: lib/bds/desktop/menu_bar.ex:252
|
||||
#: lib/bds/tui.ex:973
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Regenerate Calendar"
|
||||
msgstr ""
|
||||
@@ -2109,12 +2134,13 @@ msgstr ""
|
||||
msgid "Regenerate Missing Thumbnails"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:218
|
||||
#: lib/bds/desktop/menu_bar.ex:249
|
||||
#: lib/bds/tui.ex:967
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reindex Text"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:208
|
||||
#: lib/bds/desktop/menu_bar.ex:239
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reload"
|
||||
msgstr ""
|
||||
@@ -2145,7 +2171,7 @@ msgstr ""
|
||||
msgid "Render in Lists"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:200
|
||||
#: lib/bds/desktop/menu_bar.ex:231
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Replace"
|
||||
msgstr ""
|
||||
@@ -2162,12 +2188,12 @@ msgstr ""
|
||||
msgid "Replace Media File"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:236
|
||||
#: lib/bds/desktop/menu_bar.ex:267
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Report Issue"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:210
|
||||
#: lib/bds/desktop/menu_bar.ex:241
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reset Zoom"
|
||||
msgstr ""
|
||||
@@ -2222,7 +2248,7 @@ msgstr ""
|
||||
msgid "SSH Mode"
|
||||
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:299
|
||||
#: 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:219
|
||||
#: lib/bds/desktop/shell_live/script_editor.ex:234
|
||||
#: lib/bds/tui.ex:838
|
||||
#: lib/bds/ui/registry.ex:38
|
||||
#: lib/bds/ui/sidebar.ex:63
|
||||
#: lib/bds/ui/sidebar.ex:115
|
||||
@@ -2340,7 +2367,7 @@ msgstr ""
|
||||
msgid "Select & Analyze"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:198
|
||||
#: lib/bds/desktop/menu_bar.ex:229
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select All"
|
||||
msgstr ""
|
||||
@@ -2422,6 +2449,9 @@ msgid "Site"
|
||||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Site Validation"
|
||||
@@ -2469,7 +2499,7 @@ msgstr ""
|
||||
msgid "Starting..."
|
||||
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:1233
|
||||
#, 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:253
|
||||
#: 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:103
|
||||
#: lib/bds/ui/sidebar.ex:289
|
||||
@@ -2551,8 +2582,9 @@ msgstr ""
|
||||
msgid "Tags"
|
||||
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/tui.ex:403
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Tasks"
|
||||
msgstr ""
|
||||
@@ -2597,6 +2629,7 @@ msgstr ""
|
||||
#: 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:191
|
||||
#: lib/bds/tui.ex:837
|
||||
#: lib/bds/ui/registry.ex:46
|
||||
#: lib/bds/ui/sidebar.ex:71
|
||||
#: lib/bds/ui/sidebar.ex:122
|
||||
@@ -2646,12 +2679,12 @@ msgstr ""
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:206
|
||||
#: lib/bds/desktop/menu_bar.ex:237
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle Assistant Sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:207
|
||||
#: lib/bds/desktop/menu_bar.ex:238
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle Dev Tools"
|
||||
msgstr ""
|
||||
@@ -2662,17 +2695,17 @@ msgstr ""
|
||||
msgid "Toggle Filters"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:213
|
||||
#: lib/bds/desktop/menu_bar.ex:244
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle Full Screen"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:205
|
||||
#: lib/bds/desktop/menu_bar.ex:236
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle Panel"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:204
|
||||
#: lib/bds/desktop/menu_bar.ex:235
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle Sidebar"
|
||||
msgstr ""
|
||||
@@ -2768,7 +2801,7 @@ msgstr ""
|
||||
msgid "URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:192
|
||||
#: lib/bds/desktop/menu_bar.ex:223
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Undo"
|
||||
msgstr ""
|
||||
@@ -2793,7 +2826,7 @@ msgid "Unsaved"
|
||||
msgstr ""
|
||||
|
||||
#: 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Untitled"
|
||||
@@ -2818,7 +2851,8 @@ msgstr ""
|
||||
msgid "Updated URLs"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:228
|
||||
#: lib/bds/desktop/menu_bar.ex:259
|
||||
#: lib/bds/tui.ex:985
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Upload Site"
|
||||
msgstr ""
|
||||
@@ -2845,22 +2879,24 @@ msgstr ""
|
||||
msgid "Validate"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:227
|
||||
#: lib/bds/desktop/menu_bar.ex:258
|
||||
#: lib/bds/tui.ex:963
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Validate Site"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:222
|
||||
#: lib/bds/desktop/menu_bar.ex:253
|
||||
#: lib/bds/tui.ex:976
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Validate Translations"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:180
|
||||
#: lib/bds/desktop/menu_bar.ex:209
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:235
|
||||
#: lib/bds/desktop/menu_bar.ex:266
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "View on GitHub"
|
||||
msgstr ""
|
||||
@@ -2904,12 +2940,12 @@ msgstr ""
|
||||
msgid "You"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:211
|
||||
#: lib/bds/desktop/menu_bar.ex:242
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Zoom In"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:212
|
||||
#: lib/bds/desktop/menu_bar.ex:243
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Zoom Out"
|
||||
msgstr ""
|
||||
@@ -3231,12 +3267,12 @@ msgstr ""
|
||||
msgid "Comparing database and filesystem metadata"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:674
|
||||
#: lib/bds/desktop/shell_live.ex:684
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Added %{count} images to post"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:642
|
||||
#: lib/bds/desktop/shell_live.ex:652
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Added %{title}"
|
||||
msgstr ""
|
||||
@@ -3256,18 +3292,18 @@ msgstr ""
|
||||
msgid "Image Import Concurrency"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:447
|
||||
#: lib/bds/desktop/shell_live.ex:460
|
||||
#: lib/bds/desktop/shell_live.ex:641
|
||||
#: lib/bds/desktop/shell_live.ex:673
|
||||
#: lib/bds/desktop/shell_live.ex:684
|
||||
#: lib/bds/desktop/shell_live.ex:695
|
||||
#: lib/bds/desktop/shell_live.ex:444
|
||||
#: lib/bds/desktop/shell_live.ex:457
|
||||
#: lib/bds/desktop/shell_live.ex:651
|
||||
#: lib/bds/desktop/shell_live.ex:683
|
||||
#: lib/bds/desktop/shell_live.ex:694
|
||||
#: lib/bds/desktop/shell_live.ex:705
|
||||
#: lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex:420
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Add Gallery Images"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:696
|
||||
#: lib/bds/desktop/shell_live.ex:706
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to process %{path}: %{reason}"
|
||||
msgstr ""
|
||||
@@ -3458,12 +3494,12 @@ msgstr ""
|
||||
msgid "untracked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:792
|
||||
#: lib/bds/desktop/shell_live.ex:812
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Blogmark"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:835
|
||||
#: lib/bds/desktop/shell_live.ex:855
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open a project before importing a blogmark."
|
||||
msgstr ""
|
||||
@@ -3504,7 +3540,7 @@ msgstr ""
|
||||
msgid "Failed to copy bookmarklet to clipboard"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/shell_live.ex:804
|
||||
#: lib/bds/desktop/shell_live.ex:824
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The project this blogmark targets does not exist here."
|
||||
msgstr ""
|
||||
@@ -3514,7 +3550,8 @@ msgstr ""
|
||||
msgid "Suggested tags"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:226
|
||||
#: lib/bds/desktop/menu_bar.ex:257
|
||||
#: lib/bds/tui.ex:964
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Force Render Site"
|
||||
msgstr ""
|
||||
@@ -3529,22 +3566,22 @@ msgstr ""
|
||||
msgid "Rebuild Post Links"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:231
|
||||
#: lib/bds/desktop/menu_bar.ex:262
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Bring All to Front"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:229
|
||||
#: lib/bds/desktop/menu_bar.ex:260
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Minimize"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:182
|
||||
#: lib/bds/desktop/menu_bar.ex:211
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Window"
|
||||
msgstr ""
|
||||
|
||||
#: lib/bds/desktop/menu_bar.ex:230
|
||||
#: lib/bds/desktop/menu_bar.ex:261
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Zoom"
|
||||
msgstr ""
|
||||
@@ -3591,12 +3628,12 @@ msgstr ""
|
||||
msgid "Could not load models"
|
||||
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
|
||||
msgid "Could not save AI settings"
|
||||
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
|
||||
msgid "Loaded %{count} models."
|
||||
msgstr ""
|
||||
@@ -3606,7 +3643,209 @@ msgstr ""
|
||||
msgid "OK"
|
||||
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
|
||||
msgid "The endpoint returned no models. You can still type a model name manually."
|
||||
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
59
specs/events.allium
Normal file
@@ -0,0 +1,59 @@
|
||||
-- allium: 1
|
||||
-- Domain Event Bus (issue #26, phase 2)
|
||||
-- Scope: extension (multi-client synchronization over Phoenix.PubSub)
|
||||
-- Distilled from: lib/bds/events.ex, context broadcast call sites,
|
||||
-- lib/bds/desktop/shell_live.ex, lib/bds/cli_sync/watcher.ex
|
||||
|
||||
entity EntityChangedEvent {
|
||||
entity: String -- post, media, tag, template, script
|
||||
entity_id: String
|
||||
action: created | updated | deleted
|
||||
-- Same topic and payload shape as the CLI sync watcher, so one
|
||||
-- subscription covers in-app mutations and external CLI writes.
|
||||
}
|
||||
|
||||
entity SettingsChangedEvent {
|
||||
key: String -- e.g. ui.language
|
||||
}
|
||||
|
||||
surface EventBusSurface {
|
||||
facing _: EventBus
|
||||
|
||||
provides:
|
||||
ContextMutationSucceeded(entity, entity_id, action)
|
||||
GlobalSettingWritten(key)
|
||||
ClientSubscribed()
|
||||
}
|
||||
|
||||
rule BroadcastEntityMutation {
|
||||
when: ContextMutationSucceeded(entity, entity_id, action)
|
||||
-- Posts, Media, Tags, Templates, Scripts broadcast after every
|
||||
-- successful create/update/publish/delete (publish maps to updated).
|
||||
ensures: EntityChangedEvent.created(
|
||||
entity: entity,
|
||||
entity_id: entity_id,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
|
||||
rule BroadcastSettingsChange {
|
||||
when: GlobalSettingWritten(key)
|
||||
ensures: SettingsChangedEvent.created(key: key)
|
||||
}
|
||||
|
||||
rule ClientSynchronization {
|
||||
when: ClientSubscribed()
|
||||
-- Every shell (LiveView GUI or TUI session) subscribes on mount and
|
||||
-- refreshes affected views on each event; deleted entities close
|
||||
-- their open tabs. This keeps all connected clients synchronized
|
||||
-- regardless of which client or pipeline originated the change.
|
||||
ensures: ClientRefreshOnEvent()
|
||||
}
|
||||
|
||||
rule ServerSideUiLanguage {
|
||||
when: GlobalSettingWritten(key: "ui.language")
|
||||
-- The UI language is a single server-side setting: changing it in any
|
||||
-- client persists it and re-renders every connected client. The OS
|
||||
-- locale is only the fallback when the setting is unset.
|
||||
ensures: AllClientsRelocalized()
|
||||
}
|
||||
73
specs/server.allium
Normal file
73
specs/server.allium
Normal 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
106
specs/tui.allium
Normal 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()
|
||||
}
|
||||
98
test/bds/desktop/remote_connection_test.exs
Normal file
98
test/bds/desktop/remote_connection_test.exs
Normal 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
123
test/bds/events_test.exs
Normal 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
|
||||
@@ -55,43 +55,43 @@ defmodule BDS.GalleryPipelineTest do
|
||||
test "counts [[gallery]] macro as gallery content" do
|
||||
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
|
||||
|
||||
test "counts inline images as before" do
|
||||
form = %{"content" => ""}
|
||||
|
||||
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
|
||||
assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 1
|
||||
end
|
||||
|
||||
test "counts both [[gallery]] and inline images" do
|
||||
form = %{"content" => "\n[[gallery]]"}
|
||||
|
||||
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
|
||||
assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 1
|
||||
end
|
||||
|
||||
test "returns 0 when no gallery marks present" do
|
||||
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
|
||||
|
||||
test "handles empty content" do
|
||||
form = %{}
|
||||
|
||||
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 0
|
||||
assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 0
|
||||
end
|
||||
|
||||
test "counts multiple [[gallery]] occurrences" do
|
||||
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
|
||||
|
||||
test "is case insensitive for [[gallery]]" do
|
||||
form = %{"content" => "[[Gallery]]"}
|
||||
|
||||
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
|
||||
assert BDS.UI.PostEditor.Metadata.gallery_count(form) == 1
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ defmodule BDS.PostTranslationsTest do
|
||||
"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 ai_tasks() == []
|
||||
|
||||
@@ -127,7 +127,9 @@ defmodule BDS.Scripting.JobTest do
|
||||
_running_job = wait_for_job(job.id, &(&1.status == :running))
|
||||
|
||||
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))
|
||||
assert cancelled_job.finished_at != nil
|
||||
|
||||
121
test/bds/server_test.exs
Normal file
121
test/bds/server_test.exs
Normal 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
434
test/bds/tui_test.exs
Normal 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
|
||||
64
test/bds/ui/post_editor_test.exs
Normal file
64
test/bds/ui/post_editor_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user