fix: fixed bds2: protocol handling
This commit is contained in:
@@ -55,6 +55,15 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
|
|
||||||
- Never leave tests failing, even if they appear unrelated to your changes
|
- Never leave tests failing, even if they appear unrelated to your changes
|
||||||
- If a test failure is pre-existing, fix it as part of your current work
|
- If a test failure is pre-existing, fix it as part of your current work
|
||||||
|
- **Always write the full test run to a log file first, then grep that file for failures.** Never run `mix test` in the terminal and try to grep live output — the output is interleaved, truncated, and nearly impossible to parse. Pattern:
|
||||||
|
```
|
||||||
|
mix test 2>&1 > /tmp/test_run.log
|
||||||
|
grep -E "FAILED|^[ ]*1\)" /tmp/test_run.log
|
||||||
|
grep -A 15 "^[ ]*1\)" /tmp/test_run.log # details for the first failure
|
||||||
|
```
|
||||||
|
Work from the log file, not from repeated terminal runs.
|
||||||
|
|
||||||
|
> **Zero failing tests. No exceptions.**
|
||||||
- Run the full test suite (`mix test`) before considering any task complete
|
- Run the full test suite (`mix test`) before considering any task complete
|
||||||
- If you cannot fix a test, explain why and propose a solution
|
- If you cannot fix a test, explain why and propose a solution
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ defmodule BDS.Blogmark do
|
|||||||
@scheme "bds2"
|
@scheme "bds2"
|
||||||
@new_post_action "new-post"
|
@new_post_action "new-post"
|
||||||
|
|
||||||
|
# Input-hardening limits mirrored from the legacy bDS app (shared/blogmark.ts).
|
||||||
|
@max_title_length 200
|
||||||
|
@max_url_length 2048
|
||||||
|
@control_chars ~r/[\x00-\x1F\x7F]/
|
||||||
|
|
||||||
@type candidate :: %{required(String.t()) => term()}
|
@type candidate :: %{required(String.t()) => term()}
|
||||||
|
|
||||||
@type receive_result :: %{
|
@type receive_result :: %{
|
||||||
@@ -34,7 +39,8 @@ defmodule BDS.Blogmark do
|
|||||||
Parses a `bds2://new-post` deep link into a post candidate map.
|
Parses a `bds2://new-post` deep link into a post candidate map.
|
||||||
|
|
||||||
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
|
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
|
||||||
`tags` and `categories`, or an error when the scheme or action is unsupported.
|
`tags`, `categories` and optional `project_id`, or an error when the scheme
|
||||||
|
or action is unsupported.
|
||||||
"""
|
"""
|
||||||
@spec parse_deep_link(String.t()) ::
|
@spec parse_deep_link(String.t()) ::
|
||||||
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
|
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
|
||||||
@@ -54,10 +60,27 @@ defmodule BDS.Blogmark do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the `project_id` a deep link targets, or `nil` when the link carries
|
||||||
|
none or cannot be parsed. Used by the shell to switch to the link's project
|
||||||
|
before importing.
|
||||||
|
"""
|
||||||
|
@spec deep_link_project_id(String.t()) :: String.t() | nil
|
||||||
|
def deep_link_project_id(url) when is_binary(url) do
|
||||||
|
case parse_deep_link(url) do
|
||||||
|
{:ok, %{"project_id" => project_id}} -> project_id
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Receives a blogmark deep link for `project_id`: parses it, runs the transform
|
Receives a blogmark deep link for `project_id`: parses it, runs the transform
|
||||||
pipeline, and creates a draft post from the resulting candidate.
|
pipeline, and creates a draft post from the resulting candidate.
|
||||||
|
|
||||||
|
When the deep link URL carries a `project_id` query parameter, it takes
|
||||||
|
precedence over the passed `project_id` so the bookmarklet can target a
|
||||||
|
specific project regardless of which project is currently active.
|
||||||
|
|
||||||
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
|
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
|
||||||
budget-enforced transform messages and `errors` records any failed transforms.
|
budget-enforced transform messages and `errors` records any failed transforms.
|
||||||
"""
|
"""
|
||||||
@@ -66,10 +89,12 @@ defmodule BDS.Blogmark do
|
|||||||
def receive_deep_link(project_id, url, opts \\ [])
|
def receive_deep_link(project_id, url, opts \\ [])
|
||||||
when is_binary(project_id) and is_binary(url) and is_list(opts) do
|
when is_binary(project_id) and is_binary(url) and is_list(opts) do
|
||||||
with {:ok, candidate} <- parse_deep_link(url),
|
with {:ok, candidate} <- parse_deep_link(url),
|
||||||
|
target_project <- resolve_target_project(project_id, candidate),
|
||||||
|
candidate <- apply_default_content(candidate),
|
||||||
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
|
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
|
||||||
Transforms.run(project_id, candidate, opts),
|
Transforms.run(target_project, candidate, opts),
|
||||||
data <- apply_default_category(project_id, data),
|
data <- apply_default_category(target_project, data),
|
||||||
{:ok, post} <- create_draft(project_id, data) do
|
{:ok, post} <- create_draft(target_project, data) do
|
||||||
{:ok, %{post: post, toasts: toasts, errors: errors}}
|
{:ok, %{post: post, toasts: toasts, errors: errors}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -77,15 +102,102 @@ defmodule BDS.Blogmark do
|
|||||||
defp candidate_from_query(query) do
|
defp candidate_from_query(query) do
|
||||||
params = URI.decode_query(query || "")
|
params = URI.decode_query(query || "")
|
||||||
|
|
||||||
|
project_id =
|
||||||
|
case Map.get(params, "project_id") do
|
||||||
|
nil -> nil
|
||||||
|
"" -> nil
|
||||||
|
id -> id
|
||||||
|
end
|
||||||
|
|
||||||
|
url = sanitize_http_url(Map.get(params, "url"))
|
||||||
|
|
||||||
%{
|
%{
|
||||||
"title" => Map.get(params, "title", "") |> to_string(),
|
"title" => sanitize_title(Map.get(params, "title"), url_host(url)),
|
||||||
"url" => optional(params, "url"),
|
"url" => url,
|
||||||
"content" => optional(params, "content"),
|
"content" => optional(params, "content"),
|
||||||
"tags" => list_param(params, "tags"),
|
"tags" => list_param(params, "tags"),
|
||||||
"categories" => list_param(params, "categories")
|
"categories" => list_param(params, "categories"),
|
||||||
|
"project_id" => project_id
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Strip control characters, trim, cap length; fall back to the URL host when
|
||||||
|
# blank — mirrors bDS sanitizeTitle. Title/url are single-line fields, so
|
||||||
|
# control-character stripping is safe here (unlike the multi-line body).
|
||||||
|
defp sanitize_title(raw, fallback_host) do
|
||||||
|
title =
|
||||||
|
raw
|
||||||
|
|> to_string()
|
||||||
|
|> strip_control()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.slice(0, @max_title_length)
|
||||||
|
|
||||||
|
if title == "", do: fallback_host || "", else: title
|
||||||
|
end
|
||||||
|
|
||||||
|
# Accept only http(s) URLs, strip credentials and fragment, enforce a length
|
||||||
|
# cap; anything else collapses to nil so it can never reach the post body.
|
||||||
|
defp sanitize_http_url(raw) when is_binary(raw) do
|
||||||
|
trimmed = raw |> strip_control() |> String.trim()
|
||||||
|
|
||||||
|
case URI.parse(trimmed) do
|
||||||
|
%URI{scheme: scheme, host: host} = uri
|
||||||
|
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
|
||||||
|
normalized = URI.to_string(%URI{uri | userinfo: nil, fragment: nil})
|
||||||
|
if String.length(normalized) <= @max_url_length, do: normalized, else: nil
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_http_url(_other), do: nil
|
||||||
|
|
||||||
|
defp url_host(url) when is_binary(url), do: URI.parse(url).host
|
||||||
|
defp url_host(_other), do: nil
|
||||||
|
|
||||||
|
defp strip_control(value), do: String.replace(value, @control_chars, "")
|
||||||
|
|
||||||
|
# If the deep link carries a project_id, route to that project; otherwise
|
||||||
|
# use the currently active project passed by the caller. This lets the
|
||||||
|
# bookmarklet target a specific project even when a different one is open.
|
||||||
|
defp resolve_target_project(_caller_project_id, %{"project_id" => pid}) when is_binary(pid) do
|
||||||
|
pid
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_target_project(project_id, _candidate), do: project_id
|
||||||
|
|
||||||
|
# Match the legacy bDS app: when the bookmarklet supplies no body, seed the
|
||||||
|
# post content with a markdown link to the bookmarked page so transforms run
|
||||||
|
# against the same input. Explicit content always wins.
|
||||||
|
defp apply_default_content(candidate) do
|
||||||
|
with nil <- optional_string(Map.get(candidate, "content")),
|
||||||
|
url when is_binary(url) <- optional_string(Map.get(candidate, "url")) do
|
||||||
|
title = Map.get(candidate, "title", "")
|
||||||
|
Map.put(candidate, "content", blogmark_markdown_link(title, url))
|
||||||
|
else
|
||||||
|
_ -> candidate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp blogmark_markdown_link(title, url) do
|
||||||
|
"[" <> escape_markdown_link_text(title) <> "](" <> url <> ")"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mirrors bDS escapeMarkdownLinkText: backslash first, then brackets/parens,
|
||||||
|
# then collapse newlines to spaces.
|
||||||
|
defp escape_markdown_link_text(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.replace("\\", "\\\\")
|
||||||
|
|> String.replace("[", "\\[")
|
||||||
|
|> String.replace("]", "\\]")
|
||||||
|
|> String.replace("(", "\\(")
|
||||||
|
|> String.replace(")", "\\)")
|
||||||
|
|> String.replace(~r/\r?\n/, " ")
|
||||||
|
end
|
||||||
|
|
||||||
defp optional(params, key) do
|
defp optional(params, key) do
|
||||||
case Map.get(params, key) do
|
case Map.get(params, key) do
|
||||||
nil -> nil
|
nil -> nil
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
defmodule BDS.Desktop.DeepLink do
|
defmodule BDS.Desktop.DeepLink do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
|
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
|
||||||
shell (spec: script.allium `BlogmarkReceived`).
|
live shell (spec: script.allium `BlogmarkReceived`).
|
||||||
|
|
||||||
On macOS the `BDS2.app` bundle registers `bds2://` as a custom URL scheme via
|
On macOS the `BDS2.app` bundle registers `bds2://` as a custom URL scheme via
|
||||||
the `CFBundleURLTypes` entry in its `Info.plist` (built by `BDS.MacBundle` /
|
the `CFBundleURLTypes` entry in its `Info.plist` (built by `BDS.MacBundle` /
|
||||||
`mix bds.bundle.macos`). When the browser bookmarklet navigates to
|
`mix bds.bundle.macos`). When the browser bookmarklet navigates to
|
||||||
`bds2://new-post?title=&url=`, the OS launches/raises the app and `Desktop.Env`
|
`bds2://new-post?title=&url=`, the OS launches/raises the app and `Desktop.Env`
|
||||||
delivers an `{:open_url, [url]}` event. This GenServer subscribes to those
|
relays erlang `:wx`'s `{:open_url, Url}` app event (where `Url` is the URL as a
|
||||||
events and forwards recognised `bds2://` links to the live shell over PubSub,
|
charlist). This GenServer subscribes to those events and forwards recognised
|
||||||
where `BDS.Blogmark` turns them into draft posts.
|
`bds2://` links to the live shell.
|
||||||
|
|
||||||
|
Cold start matters here: when the bookmarklet *launches* the app, the deep
|
||||||
|
link arrives during startup, before the LiveView shell's websocket has
|
||||||
|
connected. Such links are queued and replayed the moment a shell registers via
|
||||||
|
`attach/2`; while a shell is connected, links are delivered to it directly.
|
||||||
|
|
||||||
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
|
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
|
||||||
installs do not contend for the same registration.
|
installs do not contend for the same registration.
|
||||||
@@ -19,8 +24,6 @@ defmodule BDS.Desktop.DeepLink do
|
|||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias BDS.CliSync.Watcher
|
|
||||||
|
|
||||||
@scheme "bds2://"
|
@scheme "bds2://"
|
||||||
|
|
||||||
def child_spec(opts) do
|
def child_spec(opts) do
|
||||||
@@ -31,32 +34,69 @@ defmodule BDS.Desktop.DeepLink do
|
|||||||
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
|
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@doc """
|
||||||
def init(opts) do
|
Registers the live shell `pid` as the deep-link sink and immediately replays
|
||||||
pubsub = Keyword.get(opts, :pubsub, BDS.PubSub)
|
any links that arrived before a shell was connected. Future links are sent to
|
||||||
topic = Keyword.get(opts, :topic, Watcher.topic())
|
this pid until it dies, after which links queue again.
|
||||||
|
"""
|
||||||
subscribe_to_env()
|
@spec attach(pid(), GenServer.server()) :: :ok
|
||||||
|
def attach(shell_pid, server \\ __MODULE__) when is_pid(shell_pid) do
|
||||||
{:ok, %{pubsub: pubsub, topic: topic}}
|
GenServer.call(server, {:attach, shell_pid})
|
||||||
end
|
end
|
||||||
|
|
||||||
# Desktop.Env delivers OS events as {event_name, args} tuples.
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:open_url, [url | _rest]}, state) when is_binary(url) do
|
def init(_opts) do
|
||||||
{:noreply, route(url, state)}
|
subscribe_to_env()
|
||||||
|
{:ok, %{shell: nil, pending: []}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:attach, pid}, _from, state) do
|
||||||
|
Process.monitor(pid)
|
||||||
|
|
||||||
|
state.pending
|
||||||
|
|> Enum.reverse()
|
||||||
|
|> Enum.each(&send(pid, {:blogmark_deep_link, &1}))
|
||||||
|
|
||||||
|
{:reply, :ok, %{state | shell: pid, pending: []}}
|
||||||
|
end
|
||||||
|
|
||||||
|
# erlang's :wx delivers macOS app events as `{:open_url, Url}` where `Url` is
|
||||||
|
# the URL *string itself* — a charlist on current OTP, not a list of URLs
|
||||||
|
# (see `wx:subscribe_events/0`). Desktop.Env forwards it unchanged, so the
|
||||||
|
# payload must be normalised to a binary before routing.
|
||||||
|
@impl true
|
||||||
|
def handle_info({:open_url, payload}, state) do
|
||||||
|
{:noreply, route(to_url(payload), state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:DOWN, _ref, :process, pid, _reason}, %{shell: pid} = state) do
|
||||||
|
{:noreply, %{state | shell: nil}}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info(_message, state), do: {:noreply, state}
|
def handle_info(_message, state), do: {:noreply, state}
|
||||||
|
|
||||||
defp route(url, state) do
|
# Accept the charlist wx actually sends, a bare binary, or a [binary | _] list
|
||||||
if String.starts_with?(url, @scheme) do
|
# (the shape Desktop.Env's docs imply), normalising all to a binary URL.
|
||||||
Phoenix.PubSub.broadcast(state.pubsub, state.topic, {:blogmark_deep_link, url})
|
defp to_url(value) when is_binary(value), do: value
|
||||||
else
|
defp to_url([first | _]) when is_binary(first), do: first
|
||||||
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
|
defp to_url(value) when is_list(value), do: List.to_string(value)
|
||||||
end
|
defp to_url(_other), do: ""
|
||||||
|
|
||||||
|
defp route(url, state) do
|
||||||
|
cond do
|
||||||
|
not String.starts_with?(url, @scheme) ->
|
||||||
|
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
|
||||||
state
|
state
|
||||||
|
|
||||||
|
is_pid(state.shell) and Process.alive?(state.shell) ->
|
||||||
|
send(state.shell, {:blogmark_deep_link, url})
|
||||||
|
state
|
||||||
|
|
||||||
|
true ->
|
||||||
|
# No shell connected yet (cold start) — hold the link until one attaches.
|
||||||
|
%{state | pending: [url | state.pending]}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Desktop.Env is only present when the wx desktop adapter is running. Guard the
|
# Desktop.Env is only present when the wx desktop adapter is running. Guard the
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
if connected do
|
if connected do
|
||||||
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
|
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
|
||||||
|
attach_deep_link()
|
||||||
Process.send_after(self(), :refresh_task_status, @refresh_interval)
|
Process.send_after(self(), :refresh_task_status, @refresh_interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -737,9 +738,56 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
# create a draft post, open it in the editor, and surface transform toasts.
|
# create a draft post, open it in the editor, and surface transform toasts.
|
||||||
defp handle_blogmark_deep_link(socket, url) do
|
defp handle_blogmark_deep_link(socket, url) do
|
||||||
title = dgettext("ui", "Blogmark")
|
title = dgettext("ui", "Blogmark")
|
||||||
|
link_project_id = Blogmark.deep_link_project_id(url)
|
||||||
|
active = current_project_id(socket)
|
||||||
|
|
||||||
case current_project_id(socket) do
|
cond do
|
||||||
project_id when is_binary(project_id) ->
|
# A specific project was requested but does not exist in this install
|
||||||
|
# (e.g. a bookmarklet copied from another instance). Never guess another
|
||||||
|
# project — tell the user, import nothing.
|
||||||
|
is_binary(link_project_id) and Projects.get_project(link_project_id) == nil ->
|
||||||
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
title,
|
||||||
|
dgettext("ui", "The project this blogmark targets does not exist here."),
|
||||||
|
url,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
|
||||||
|
# A specific, existing project that isn't active: switch to it first so
|
||||||
|
# the new post is shown in context, then import there.
|
||||||
|
is_binary(link_project_id) and link_project_id != active ->
|
||||||
|
case Projects.set_active_project(link_project_id) do
|
||||||
|
{:ok, _project} ->
|
||||||
|
socket
|
||||||
|
|> assign(:project_menu_open, false)
|
||||||
|
|> assign(:sidebar_filters_by_view, %{})
|
||||||
|
|> reload_shell(Workbench.new())
|
||||||
|
|> UrlState.push()
|
||||||
|
|> import_blogmark(link_project_id, url, title)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
append_output_entry(socket, title, inspect(reason), url, "error")
|
||||||
|
end
|
||||||
|
|
||||||
|
# The link names the already-active project, or no project_id was given
|
||||||
|
# and a project is active: import into the active project.
|
||||||
|
is_binary(active) ->
|
||||||
|
import_blogmark(socket, active, url, title)
|
||||||
|
|
||||||
|
# No project_id and nothing active.
|
||||||
|
true ->
|
||||||
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
title,
|
||||||
|
dgettext("ui", "Open a project before importing a blogmark."),
|
||||||
|
url,
|
||||||
|
"warning"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp import_blogmark(socket, project_id, url, title) do
|
||||||
case Blogmark.receive_deep_link(project_id, url) do
|
case Blogmark.receive_deep_link(project_id, url) do
|
||||||
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
|
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
|
||||||
socket
|
socket
|
||||||
@@ -759,16 +807,6 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
append_output_entry(socket, title, inspect(reason), url, "error")
|
append_output_entry(socket, title, inspect(reason), url, "error")
|
||||||
end
|
end
|
||||||
|
|
||||||
_ ->
|
|
||||||
append_output_entry(
|
|
||||||
socket,
|
|
||||||
title,
|
|
||||||
dgettext("ui", "Open a project before importing a blogmark."),
|
|
||||||
url,
|
|
||||||
"warning"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp append_blogmark_toasts(socket, title, toasts) do
|
defp append_blogmark_toasts(socket, title, toasts) do
|
||||||
@@ -838,6 +876,14 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
|
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
|
||||||
|
|
||||||
|
# Register this shell with the deep-link router and drain any links queued
|
||||||
|
# before connect (cold start). The router is absent in headless/test runs.
|
||||||
|
defp attach_deep_link do
|
||||||
|
if Process.whereis(BDS.Desktop.DeepLink) do
|
||||||
|
BDS.Desktop.DeepLink.attach(self())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp sidebar_create_action(view), do: SidebarCreate.action(view)
|
defp sidebar_create_action(view), do: SidebarCreate.action(view)
|
||||||
|
|
||||||
defp set_page_language(socket, language) do
|
defp set_page_language(socket, language) do
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
|||||||
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
|
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
|
||||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||||
@impl true
|
@impl true
|
||||||
|
def handle_event("copy_blogmark_bookmarklet", _params, socket) do
|
||||||
|
project_id = socket.assigns.projects.active_project_id
|
||||||
|
bookmarklet = BDS.Scripting.Capabilities.AppShell.blogmark_bookmarklet(project_id: project_id)
|
||||||
|
|
||||||
|
case BDS.Scripting.Capabilities.AppShell.copy_to_clipboard(bookmarklet, []) do
|
||||||
|
true -> Notify.output(dgettext("ui", "Blogmark Bookmarklet"), dgettext("ui", "Bookmarklet copied to clipboard"), "info")
|
||||||
|
_ -> Notify.output(dgettext("ui", "Blogmark Bookmarklet"), dgettext("ui", "Failed to copy bookmarklet to clipboard"), "error")
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("change_settings_search", %{"query" => query}, socket) do
|
def handle_event("change_settings_search", %{"query" => query}, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|
|||||||
@@ -98,7 +98,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Bookmarklet") %></label></div>
|
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Bookmarklet") %></label></div>
|
||||||
<div class="setting-control"><p class="setting-description"><%= dgettext("ui", "Bookmarklet copy support is wired through the desktop runtime and project public URL.") %></p></div>
|
<div class="setting-control">
|
||||||
|
<p class="setting-description"><%= dgettext("ui", "Bookmarklet copy support is wired through the desktop runtime and project public URL.") %></p>
|
||||||
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="copy_blogmark_bookmarklet" phx-target={@myself}><%= dgettext("ui", "Copy Bookmarklet to Clipboard") %></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_project" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div>
|
<div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_project" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div>
|
||||||
|
|||||||
@@ -20,34 +20,49 @@ defmodule BDS.Scripting.Capabilities.AppShell do
|
|||||||
if test_mode?() do
|
if test_mode?() do
|
||||||
true
|
true
|
||||||
else
|
else
|
||||||
command = string_or_nil(text)
|
{executable, args} = clipboard_command()
|
||||||
|
pipe_to_clipboard(executable, args, string_or_nil(text) || "")
|
||||||
case :os.type() do
|
|
||||||
{:unix, :darwin} ->
|
|
||||||
match?({_output, 0}, System.cmd("pbcopy", [], input: command, stderr_to_stdout: true))
|
|
||||||
|
|
||||||
{:unix, _other} ->
|
|
||||||
match?(
|
|
||||||
{_output, 0},
|
|
||||||
System.cmd("xclip", ["-selection", "clipboard"],
|
|
||||||
input: command,
|
|
||||||
stderr_to_stdout: true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
{:win32, _other} ->
|
|
||||||
match?(
|
|
||||||
{_output, 0},
|
|
||||||
System.cmd("cmd", ["/c", "clip"], input: command, stderr_to_stdout: true)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
_error -> false
|
_error -> false
|
||||||
end
|
end
|
||||||
|
|
||||||
def blogmark_bookmarklet do
|
defp clipboard_command do
|
||||||
"javascript:(()=>{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='bds2://new-post?title='+t+'&url='+u;})();"
|
case :os.type() do
|
||||||
|
{:unix, :darwin} -> {"pbcopy", []}
|
||||||
|
{:win32, _other} -> {"cmd", ["/c", "clip"]}
|
||||||
|
{:unix, _other} -> {"xclip", ["-selection", "clipboard"]}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# System.cmd has no stdin option, so feed the clipboard tool through a Port.
|
||||||
|
# Closing the port signals EOF, which is what pbcopy/xclip/clip wait for.
|
||||||
|
defp pipe_to_clipboard(executable, args, input) do
|
||||||
|
case System.find_executable(executable) do
|
||||||
|
nil ->
|
||||||
|
false
|
||||||
|
|
||||||
|
path ->
|
||||||
|
port = Port.open({:spawn_executable, path}, [:binary, :use_stdio, args: args])
|
||||||
|
Port.command(port, input)
|
||||||
|
Port.close(port)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def blogmark_bookmarklet(opts \\ []) do
|
||||||
|
project_id = Keyword.get(opts, :project_id)
|
||||||
|
|
||||||
|
# Concatenate as a JS string literal (+'&project_id=...'), not raw text, so
|
||||||
|
# the generated bookmarklet stays valid JavaScript when a project is set.
|
||||||
|
project_param =
|
||||||
|
if is_binary(project_id) and project_id != "" do
|
||||||
|
"+'&project_id=" <> URI.encode_www_form(project_id) <> "'"
|
||||||
|
else
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
"javascript:(()=>{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='bds2://new-post?title='+t+'&url='+u#{project_param};})();"
|
||||||
end
|
end
|
||||||
|
|
||||||
def title_bar_metrics(opts) do
|
def title_bar_metrics(opts) do
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:589
|
#: lib/bds/ui/sidebar.ex:566
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archiv"
|
msgstr "Archiv"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:55
|
#: lib/bds/rendering/labels.ex:48
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr "Apr."
|
msgstr "Apr."
|
||||||
@@ -15,7 +15,7 @@ msgstr "Apr."
|
|||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr "Archiv"
|
msgstr "Archiv"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:71
|
#: lib/bds/rendering/labels.ex:52
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr "Aug."
|
msgstr "Aug."
|
||||||
@@ -35,27 +35,27 @@ msgstr "Kalenderdaten konnten nicht geladen werden."
|
|||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr "Kalender schließen"
|
msgstr "Kalender schließen"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:87
|
#: lib/bds/rendering/labels.ex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr "Dezember"
|
msgstr "Dezember"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:47
|
#: lib/bds/rendering/labels.ex:46
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr "Februar"
|
msgstr "Februar"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:43
|
#: lib/bds/rendering/labels.ex:45
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr "Januar"
|
msgstr "Januar"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:67
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr "Juli"
|
msgstr "Juli"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:63
|
#: lib/bds/rendering/labels.ex:50
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr "Juni"
|
msgstr "Juni"
|
||||||
@@ -75,22 +75,22 @@ msgstr "Verlinkt von"
|
|||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr "Kalender wird geladen …"
|
msgstr "Kalender wird geladen …"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:51
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr "März"
|
msgstr "März"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:59
|
#: lib/bds/rendering/labels.ex:49
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr "Mai"
|
msgstr "Mai"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:83
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr "Nov."
|
msgstr "Nov."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:79
|
#: lib/bds/rendering/labels.ex:54
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr "Oktober"
|
msgstr "Oktober"
|
||||||
@@ -110,7 +110,7 @@ msgstr "Seitennummerierung"
|
|||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr "Suchen..."
|
msgstr "Suchen..."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:75
|
#: lib/bds/rendering/labels.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr "Sept."
|
msgstr "Sept."
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:589
|
#: lib/bds/ui/sidebar.ex:566
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:55
|
#: lib/bds/rendering/labels.ex:48
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -15,7 +15,7 @@ msgstr ""
|
|||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:71
|
#: lib/bds/rendering/labels.ex:52
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -35,27 +35,27 @@ msgstr ""
|
|||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:87
|
#: lib/bds/rendering/labels.ex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:47
|
#: lib/bds/rendering/labels.ex:46
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:43
|
#: lib/bds/rendering/labels.ex:45
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:67
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:63
|
#: lib/bds/rendering/labels.ex:50
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -75,22 +75,22 @@ msgstr ""
|
|||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:51
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:59
|
#: lib/bds/rendering/labels.ex:49
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:83
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:79
|
#: lib/bds/rendering/labels.ex:54
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -110,7 +110,7 @@ msgstr ""
|
|||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:75
|
#: lib/bds/rendering/labels.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:589
|
#: lib/bds/ui/sidebar.ex:566
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archivo"
|
msgstr "Archivo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:55
|
#: lib/bds/rendering/labels.ex:48
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr "abril"
|
msgstr "abril"
|
||||||
@@ -15,7 +15,7 @@ msgstr "abril"
|
|||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr "Archivo"
|
msgstr "Archivo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:71
|
#: lib/bds/rendering/labels.ex:52
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr "agosto"
|
msgstr "agosto"
|
||||||
@@ -35,27 +35,27 @@ msgstr "No se pudieron cargar los datos del calendario."
|
|||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr "Cerrar calendario"
|
msgstr "Cerrar calendario"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:87
|
#: lib/bds/rendering/labels.ex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr "diciembre"
|
msgstr "diciembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:47
|
#: lib/bds/rendering/labels.ex:46
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr "febrero"
|
msgstr "febrero"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:43
|
#: lib/bds/rendering/labels.ex:45
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr "enero"
|
msgstr "enero"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:67
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr "julio"
|
msgstr "julio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:63
|
#: lib/bds/rendering/labels.ex:50
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr "junio"
|
msgstr "junio"
|
||||||
@@ -75,22 +75,22 @@ msgstr "Enlazado desde"
|
|||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr "Cargando calendario…"
|
msgstr "Cargando calendario…"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:51
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr "marzo"
|
msgstr "marzo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:59
|
#: lib/bds/rendering/labels.ex:49
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr "mayo"
|
msgstr "mayo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:83
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr "noviembre"
|
msgstr "noviembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:79
|
#: lib/bds/rendering/labels.ex:54
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr "octubre"
|
msgstr "octubre"
|
||||||
@@ -110,7 +110,7 @@ msgstr "Paginación"
|
|||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr "Buscar..."
|
msgstr "Buscar..."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:75
|
#: lib/bds/rendering/labels.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr "septiembre"
|
msgstr "septiembre"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:589
|
#: lib/bds/ui/sidebar.ex:566
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archives"
|
msgstr "Archives"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:55
|
#: lib/bds/rendering/labels.ex:48
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr "avril"
|
msgstr "avril"
|
||||||
@@ -15,7 +15,7 @@ msgstr "avril"
|
|||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr "Archives"
|
msgstr "Archives"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:71
|
#: lib/bds/rendering/labels.ex:52
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr "août"
|
msgstr "août"
|
||||||
@@ -35,27 +35,27 @@ msgstr "Impossible de charger les données du calendrier."
|
|||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr "Fermer le calendrier"
|
msgstr "Fermer le calendrier"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:87
|
#: lib/bds/rendering/labels.ex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr "décembre"
|
msgstr "décembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:47
|
#: lib/bds/rendering/labels.ex:46
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr "février"
|
msgstr "février"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:43
|
#: lib/bds/rendering/labels.ex:45
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr "janvier"
|
msgstr "janvier"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:67
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr "juillet"
|
msgstr "juillet"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:63
|
#: lib/bds/rendering/labels.ex:50
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr "juin"
|
msgstr "juin"
|
||||||
@@ -75,22 +75,22 @@ msgstr "Lié depuis"
|
|||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr "Chargement du calendrier…"
|
msgstr "Chargement du calendrier…"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:51
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr "mars"
|
msgstr "mars"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:59
|
#: lib/bds/rendering/labels.ex:49
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr "mai"
|
msgstr "mai"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:83
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr "novembre"
|
msgstr "novembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:79
|
#: lib/bds/rendering/labels.ex:54
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr "octobre"
|
msgstr "octobre"
|
||||||
@@ -110,7 +110,7 @@ msgstr "Navigation paginée"
|
|||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr "Rechercher..."
|
msgstr "Rechercher..."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:75
|
#: lib/bds/rendering/labels.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr "septembre"
|
msgstr "septembre"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:589
|
#: lib/bds/ui/sidebar.ex:566
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archivio"
|
msgstr "Archivio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:55
|
#: lib/bds/rendering/labels.ex:48
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr "aprile"
|
msgstr "aprile"
|
||||||
@@ -15,7 +15,7 @@ msgstr "aprile"
|
|||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr "Archivio"
|
msgstr "Archivio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:71
|
#: lib/bds/rendering/labels.ex:52
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr "agosto"
|
msgstr "agosto"
|
||||||
@@ -35,27 +35,27 @@ msgstr "Impossibile caricare i dati del calendario."
|
|||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr "Chiudi calendario"
|
msgstr "Chiudi calendario"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:87
|
#: lib/bds/rendering/labels.ex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr "dicembre"
|
msgstr "dicembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:47
|
#: lib/bds/rendering/labels.ex:46
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr "febbraio"
|
msgstr "febbraio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:43
|
#: lib/bds/rendering/labels.ex:45
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr "gennaio"
|
msgstr "gennaio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:67
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr "luglio"
|
msgstr "luglio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:63
|
#: lib/bds/rendering/labels.ex:50
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr "giugno"
|
msgstr "giugno"
|
||||||
@@ -75,22 +75,22 @@ msgstr "Collegato da"
|
|||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr "Caricamento calendario…"
|
msgstr "Caricamento calendario…"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:51
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr "marzo"
|
msgstr "marzo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:59
|
#: lib/bds/rendering/labels.ex:49
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr "maggio"
|
msgstr "maggio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:83
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr "novembre"
|
msgstr "novembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:79
|
#: lib/bds/rendering/labels.ex:54
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr "ottobre"
|
msgstr "ottobre"
|
||||||
@@ -110,7 +110,7 @@ msgstr "Paginazione"
|
|||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr "Cerca..."
|
msgstr "Cerca..."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:75
|
#: lib/bds/rendering/labels.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr "settembre"
|
msgstr "settembre"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,12 +13,12 @@ msgstr ""
|
|||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:288
|
#: lib/bds/ui/sidebar.ex:288
|
||||||
#: lib/bds/ui/sidebar.ex:589
|
#: lib/bds/ui/sidebar.ex:566
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:55
|
#: lib/bds/rendering/labels.ex:48
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -28,7 +28,7 @@ msgstr ""
|
|||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:71
|
#: lib/bds/rendering/labels.ex:52
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -48,27 +48,27 @@ msgstr ""
|
|||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:87
|
#: lib/bds/rendering/labels.ex:56
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:47
|
#: lib/bds/rendering/labels.ex:46
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:43
|
#: lib/bds/rendering/labels.ex:45
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:67
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:63
|
#: lib/bds/rendering/labels.ex:50
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -88,22 +88,22 @@ msgstr ""
|
|||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:51
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:59
|
#: lib/bds/rendering/labels.ex:49
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:83
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:79
|
#: lib/bds/rendering/labels.ex:54
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -123,7 +123,7 @@ msgstr ""
|
|||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:75
|
#: lib/bds/rendering/labels.ex:53
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -525,6 +525,9 @@
|
|||||||
"Blogmark Category": "Blogmark-Kategorie",
|
"Blogmark Category": "Blogmark-Kategorie",
|
||||||
"Blogmark Bookmarklet": "Blogmark-Bookmarklet",
|
"Blogmark Bookmarklet": "Blogmark-Bookmarklet",
|
||||||
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "Die Bookmarklet-Kopierfunktion ist über die Desktop-Laufzeit und die öffentliche Projekt-URL verdrahtet.",
|
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "Die Bookmarklet-Kopierfunktion ist über die Desktop-Laufzeit und die öffentliche Projekt-URL verdrahtet.",
|
||||||
|
"Bookmarklet copied to clipboard": "Bookmarklet in die Zwischenablage kopiert",
|
||||||
|
"Copy Bookmarklet to Clipboard": "Bookmarklet in die Zwischenablage kopieren",
|
||||||
|
"Failed to copy bookmarklet to clipboard": "Bookmarklet konnte nicht in die Zwischenablage kopiert werden",
|
||||||
"Default editing mode and diff presentation": "Standard-Bearbeitungsmodus und Diff-Darstellung",
|
"Default editing mode and diff presentation": "Standard-Bearbeitungsmodus und Diff-Darstellung",
|
||||||
"Default Editor Mode": "Standard-Bearbeitungsmodus",
|
"Default Editor Mode": "Standard-Bearbeitungsmodus",
|
||||||
"Diff View Style": "Diff-Ansicht",
|
"Diff View Style": "Diff-Ansicht",
|
||||||
|
|||||||
@@ -525,6 +525,9 @@
|
|||||||
"Blogmark Category": "Blogmark Category",
|
"Blogmark Category": "Blogmark Category",
|
||||||
"Blogmark Bookmarklet": "Blogmark Bookmarklet",
|
"Blogmark Bookmarklet": "Blogmark Bookmarklet",
|
||||||
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "Bookmarklet copy support is wired through the desktop runtime and project public URL.",
|
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "Bookmarklet copy support is wired through the desktop runtime and project public URL.",
|
||||||
|
"Bookmarklet copied to clipboard": "Bookmarklet copied to clipboard",
|
||||||
|
"Copy Bookmarklet to Clipboard": "Copy Bookmarklet to Clipboard",
|
||||||
|
"Failed to copy bookmarklet to clipboard": "Failed to copy bookmarklet to clipboard",
|
||||||
"Default editing mode and diff presentation": "Default editing mode and diff presentation",
|
"Default editing mode and diff presentation": "Default editing mode and diff presentation",
|
||||||
"Default Editor Mode": "Default Editor Mode",
|
"Default Editor Mode": "Default Editor Mode",
|
||||||
"Diff View Style": "Diff View Style",
|
"Diff View Style": "Diff View Style",
|
||||||
|
|||||||
@@ -525,6 +525,9 @@
|
|||||||
"Blogmark Category": "Categoría de blogmark",
|
"Blogmark Category": "Categoría de blogmark",
|
||||||
"Blogmark Bookmarklet": "Bookmarklet de blogmark",
|
"Blogmark Bookmarklet": "Bookmarklet de blogmark",
|
||||||
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "La copia del bookmarklet está conectada mediante el entorno de escritorio y la URL pública del proyecto.",
|
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "La copia del bookmarklet está conectada mediante el entorno de escritorio y la URL pública del proyecto.",
|
||||||
|
"Bookmarklet copied to clipboard": "Bookmarklet copiado al portapapeles",
|
||||||
|
"Copy Bookmarklet to Clipboard": "Copiar bookmarklet al portapapeles",
|
||||||
|
"Failed to copy bookmarklet to clipboard": "Error al copiar el bookmarklet al portapapeles",
|
||||||
"Default editing mode and diff presentation": "Modo de edición predeterminado y presentación de diff",
|
"Default editing mode and diff presentation": "Modo de edición predeterminado y presentación de diff",
|
||||||
"Default Editor Mode": "Modo de editor predeterminado",
|
"Default Editor Mode": "Modo de editor predeterminado",
|
||||||
"Diff View Style": "Estilo de vista diff",
|
"Diff View Style": "Estilo de vista diff",
|
||||||
|
|||||||
@@ -525,6 +525,9 @@
|
|||||||
"Blogmark Category": "Catégorie de blogmark",
|
"Blogmark Category": "Catégorie de blogmark",
|
||||||
"Blogmark Bookmarklet": "Bookmarklet blogmark",
|
"Blogmark Bookmarklet": "Bookmarklet blogmark",
|
||||||
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "La copie du bookmarklet est reliée via l’environnement desktop et l’URL publique du projet.",
|
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "La copie du bookmarklet est reliée via l’environnement desktop et l’URL publique du projet.",
|
||||||
|
"Bookmarklet copied to clipboard": "Bookmarklet copié dans le presse-papiers",
|
||||||
|
"Copy Bookmarklet to Clipboard": "Copier le bookmarklet dans le presse-papiers",
|
||||||
|
"Failed to copy bookmarklet to clipboard": "Échec de la copie du bookmarklet dans le presse-papiers",
|
||||||
"Default editing mode and diff presentation": "Mode d’édition par défaut et présentation des diffs",
|
"Default editing mode and diff presentation": "Mode d’édition par défaut et présentation des diffs",
|
||||||
"Default Editor Mode": "Mode d’édition par défaut",
|
"Default Editor Mode": "Mode d’édition par défaut",
|
||||||
"Diff View Style": "Style de vue diff",
|
"Diff View Style": "Style de vue diff",
|
||||||
|
|||||||
@@ -525,6 +525,9 @@
|
|||||||
"Blogmark Category": "Categoria blogmark",
|
"Blogmark Category": "Categoria blogmark",
|
||||||
"Blogmark Bookmarklet": "Bookmarklet blogmark",
|
"Blogmark Bookmarklet": "Bookmarklet blogmark",
|
||||||
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "La copia del bookmarklet è collegata tramite il runtime desktop e l’URL pubblica del progetto.",
|
"Bookmarklet copy support is wired through the desktop runtime and project public URL.": "La copia del bookmarklet è collegata tramite il runtime desktop e l’URL pubblica del progetto.",
|
||||||
|
"Bookmarklet copied to clipboard": "Bookmarklet copiato negli appunti",
|
||||||
|
"Copy Bookmarklet to Clipboard": "Copia bookmarklet negli appunti",
|
||||||
|
"Failed to copy bookmarklet to clipboard": "Impossibile copiare il bookmarklet negli appunti",
|
||||||
"Default editing mode and diff presentation": "Modalità di modifica predefinita e presentazione dei diff",
|
"Default editing mode and diff presentation": "Modalità di modifica predefinita e presentazione dei diff",
|
||||||
"Default Editor Mode": "Modalità editor predefinita",
|
"Default Editor Mode": "Modalità editor predefinita",
|
||||||
"Diff View Style": "Stile vista diff",
|
"Diff View Style": "Stile vista diff",
|
||||||
|
|||||||
@@ -157,8 +157,13 @@ surface SettingsViewSurface {
|
|||||||
-- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save button.
|
-- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save button.
|
||||||
|
|
||||||
@guarantee BookmarkletCopy
|
@guarantee BookmarkletCopy
|
||||||
-- Copy button copies bookmarklet JavaScript to clipboard.
|
-- Copy button copies the generated bookmarklet JavaScript to the system
|
||||||
-- Bookmarklet uses project's publicUrl to construct POST endpoint.
|
-- clipboard for pasting into a browser bookmark.
|
||||||
|
-- The bookmarklet captures the active tab's document.title and
|
||||||
|
-- location.href (each URI-encoded) and navigates to a
|
||||||
|
-- bds2://new-post?title=...&url=... deep link. When a project is active
|
||||||
|
-- its id is appended as &project_id=... so the link targets that
|
||||||
|
-- project regardless of which one is currently open.
|
||||||
|
|
||||||
@guarantee EditorSection
|
@guarantee EditorSection
|
||||||
-- Section 2: Default Editor Mode (select: Markdown/Preview),
|
-- Section 2: Default Editor Mode (select: Markdown/Preview),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ config {
|
|||||||
transform_max_toasts_per_script: Integer = 5
|
transform_max_toasts_per_script: Integer = 5
|
||||||
transform_max_toasts_total: Integer = 20
|
transform_max_toasts_total: Integer = 20
|
||||||
transform_max_toast_length: Integer = 300
|
transform_max_toast_length: Integer = 300
|
||||||
|
blogmark_max_title_length: Integer = 200
|
||||||
|
blogmark_max_url_length: Integer = 2048
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ScriptStatus {
|
enum ScriptStatus {
|
||||||
@@ -243,6 +245,36 @@ rule ExecuteTransform {
|
|||||||
requires: t.entrypoint != ""
|
requires: t.entrypoint != ""
|
||||||
ensures: TransformApplied(t, data)
|
ensures: TransformApplied(t, data)
|
||||||
|
|
||||||
|
@guarantee BlogmarkInputHardening
|
||||||
|
-- The deep link is sanitized before transforms run. Title and url have
|
||||||
|
-- control characters stripped and are trimmed. The url is kept only
|
||||||
|
-- when it is http(s) with a host, after removing any credentials and
|
||||||
|
-- fragment, and at most config.blogmark_max_url_length characters;
|
||||||
|
-- otherwise it is dropped so it can never reach the post body. The
|
||||||
|
-- title is capped at config.blogmark_max_title_length and falls back to
|
||||||
|
-- the url host when blank.
|
||||||
|
|
||||||
|
@guarantee BlogmarkDefaultBody
|
||||||
|
-- When the deep link supplies no content, the post body defaults to a
|
||||||
|
-- markdown link [title](url) to the bookmarked page, with the title
|
||||||
|
-- escaped, so transforms operate on the same candidate whether or not
|
||||||
|
-- an explicit body was provided. Explicit content always wins.
|
||||||
|
|
||||||
|
@guarantee BlogmarkProjectTargeting
|
||||||
|
-- A link's own project_id determines the target project. When it names a
|
||||||
|
-- project that exists and differs from the active one, the shell
|
||||||
|
-- switches to it before importing so the new post is shown in context.
|
||||||
|
-- When it names a project that does not exist in this install (e.g. a
|
||||||
|
-- bookmarklet copied from another instance) the import is refused with
|
||||||
|
-- an error — the post is never redirected into a different project.
|
||||||
|
-- With no link project_id, the active project is used; with no link
|
||||||
|
-- project_id and no active project the import is refused with a warning.
|
||||||
|
|
||||||
|
@guarantee BlogmarkColdStartDelivery
|
||||||
|
-- A deep link that arrives before the shell is connected (e.g. the
|
||||||
|
-- bookmarklet launching the app) is queued and replayed once a shell
|
||||||
|
-- attaches, so launching via a bookmarklet still creates the post.
|
||||||
|
|
||||||
@guarantee TransformTrigger
|
@guarantee TransformTrigger
|
||||||
-- Transform scripts are triggered automatically by blogmark import.
|
-- Transform scripts are triggered automatically by blogmark import.
|
||||||
-- Each script receives the current post candidate plus a context with
|
-- Each script receives the current post candidate plus a context with
|
||||||
|
|||||||
@@ -49,6 +49,50 @@ defmodule BDS.BlogmarkTest do
|
|||||||
assert candidate["categories"] == ["news"]
|
assert candidate["categories"] == ["news"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "strips credentials and the fragment from the url" do
|
||||||
|
url =
|
||||||
|
"bds2://new-post?title=T&url=" <>
|
||||||
|
URI.encode_www_form("https://user:pass@example.com/p?x=1#frag")
|
||||||
|
|
||||||
|
assert {:ok, candidate} = Blogmark.parse_deep_link(url)
|
||||||
|
assert candidate["url"] == "https://example.com/p?x=1"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "drops a non-http(s) url so it cannot reach the post body" do
|
||||||
|
url = "bds2://new-post?title=Unsafe&url=" <> URI.encode_www_form("javascript:alert(1)")
|
||||||
|
|
||||||
|
assert {:ok, candidate} = Blogmark.parse_deep_link(url)
|
||||||
|
assert candidate["url"] == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "drops an over-long url" do
|
||||||
|
long = "https://example.com/" <> String.duplicate("a", 2100)
|
||||||
|
url = "bds2://new-post?title=T&url=" <> URI.encode_www_form(long)
|
||||||
|
|
||||||
|
assert {:ok, candidate} = Blogmark.parse_deep_link(url)
|
||||||
|
assert candidate["url"] == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls back to the url host when the title is blank" do
|
||||||
|
url = "bds2://new-post?title=&url=" <> URI.encode_www_form("https://example.com/page")
|
||||||
|
|
||||||
|
assert {:ok, candidate} = Blogmark.parse_deep_link(url)
|
||||||
|
assert candidate["title"] == "example.com"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "strips control characters and caps the title length" do
|
||||||
|
raw_title = "A\x00B\x07C" <> String.duplicate("x", 250)
|
||||||
|
|
||||||
|
url =
|
||||||
|
"bds2://new-post?title=" <>
|
||||||
|
URI.encode_www_form(raw_title) <> "&url=https://x"
|
||||||
|
|
||||||
|
assert {:ok, candidate} = Blogmark.parse_deep_link(url)
|
||||||
|
refute String.contains?(candidate["title"], "\x00")
|
||||||
|
refute String.contains?(candidate["title"], "\x07")
|
||||||
|
assert String.length(candidate["title"]) == 200
|
||||||
|
end
|
||||||
|
|
||||||
test "rejects an unsupported scheme" do
|
test "rejects an unsupported scheme" do
|
||||||
assert {:error, :unsupported_scheme} =
|
assert {:error, :unsupported_scheme} =
|
||||||
Blogmark.parse_deep_link("bds://new-post?title=T")
|
Blogmark.parse_deep_link("bds://new-post?title=T")
|
||||||
@@ -60,6 +104,21 @@ defmodule BDS.BlogmarkTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "deep_link_project_id/1" do
|
||||||
|
test "returns the project_id carried by the link" do
|
||||||
|
assert Blogmark.deep_link_project_id("bds2://new-post?title=T&project_id=proj-7") == "proj-7"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil when absent or blank" do
|
||||||
|
assert Blogmark.deep_link_project_id("bds2://new-post?title=T") == nil
|
||||||
|
assert Blogmark.deep_link_project_id("bds2://new-post?title=T&project_id=") == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for an unparseable link" do
|
||||||
|
assert Blogmark.deep_link_project_id("bds://new-post?title=T") == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "receive_deep_link/3" do
|
describe "receive_deep_link/3" do
|
||||||
test "creates a draft post from the deep link", %{project: project} do
|
test "creates a draft post from the deep link", %{project: project} do
|
||||||
url =
|
url =
|
||||||
@@ -119,6 +178,32 @@ defmodule BDS.BlogmarkTest do
|
|||||||
assert post.categories == ["article"]
|
assert post.categories == ["article"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "defaults the body to a markdown link to the bookmarked page (parity with bDS)",
|
||||||
|
%{project: project} do
|
||||||
|
url =
|
||||||
|
"bds2://new-post?title=" <>
|
||||||
|
URI.encode_www_form("Hello (World)") <>
|
||||||
|
"&url=" <> URI.encode_www_form("https://example.com/a")
|
||||||
|
|
||||||
|
assert {:ok, %{post: post}} = Blogmark.receive_deep_link(project.id, url)
|
||||||
|
assert post.content == "[Hello \\(World\\)](https://example.com/a)"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "keeps explicit content over the default markdown link", %{project: project} do
|
||||||
|
url =
|
||||||
|
"bds2://new-post?title=T&url=https://x&content=" <> URI.encode_www_form("body text")
|
||||||
|
|
||||||
|
assert {:ok, %{post: post}} = Blogmark.receive_deep_link(project.id, url)
|
||||||
|
assert post.content == "body text"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "leaves the body empty when the deep link carries no url", %{project: project} do
|
||||||
|
assert {:ok, %{post: post}} =
|
||||||
|
Blogmark.receive_deep_link(project.id, "bds2://new-post?title=Just%20A%20Title")
|
||||||
|
|
||||||
|
assert post.content in [nil, ""]
|
||||||
|
end
|
||||||
|
|
||||||
test "returns an error for an invalid deep link", %{project: project} do
|
test "returns an error for an invalid deep link", %{project: project} do
|
||||||
assert {:error, :unsupported_scheme} =
|
assert {:error, :unsupported_scheme} =
|
||||||
Blogmark.receive_deep_link(project.id, "bds://new-post?title=T")
|
Blogmark.receive_deep_link(project.id, "bds://new-post?title=T")
|
||||||
|
|||||||
@@ -4,29 +4,74 @@ defmodule BDS.Desktop.DeepLinkTest do
|
|||||||
alias BDS.Desktop.DeepLink
|
alias BDS.Desktop.DeepLink
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
topic = "deep-link-test:#{System.unique_integer([:positive])}"
|
{:ok, pid} = DeepLink.start_link(name: nil)
|
||||||
Phoenix.PubSub.subscribe(BDS.PubSub, topic)
|
|
||||||
|
|
||||||
{:ok, pid} = DeepLink.start_link(name: nil, pubsub: BDS.PubSub, topic: topic)
|
|
||||||
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
|
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
|
||||||
|
|
||||||
%{pid: pid, topic: topic}
|
%{pid: pid}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "broadcasts a bds2:// open_url event to the shell topic", %{pid: pid} do
|
test "delivers a bds2:// link (wx charlist payload) to an attached shell", %{pid: pid} do
|
||||||
|
:ok = DeepLink.attach(self(), pid)
|
||||||
|
|
||||||
url = "bds2://new-post?title=Hello&url=https://example.com"
|
url = "bds2://new-post?title=Hello&url=https://example.com"
|
||||||
send(pid, {:open_url, [url]})
|
# This is the exact shape erlang :wx sends on macOS: {:open_url, charlist}.
|
||||||
|
send(pid, {:open_url, ~c"bds2://new-post?title=Hello&url=https://example.com"})
|
||||||
|
|
||||||
assert_receive {:blogmark_deep_link, ^url}, 500
|
assert_receive {:blogmark_deep_link, ^url}, 500
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "also accepts a binary payload", %{pid: pid} do
|
||||||
|
:ok = DeepLink.attach(self(), pid)
|
||||||
|
|
||||||
|
url = "bds2://new-post?title=Bin"
|
||||||
|
send(pid, {:open_url, url})
|
||||||
|
|
||||||
|
assert_receive {:blogmark_deep_link, ^url}, 500
|
||||||
|
end
|
||||||
|
|
||||||
|
test "queues a link that arrives before a shell attaches, then replays it", %{pid: pid} do
|
||||||
|
url = "bds2://new-post?title=Cold&url=https://example.com"
|
||||||
|
send(pid, {:open_url, String.to_charlist(url)})
|
||||||
|
|
||||||
|
# Nothing is connected yet, so nothing is delivered.
|
||||||
|
refute_receive {:blogmark_deep_link, _}, 100
|
||||||
|
|
||||||
|
:ok = DeepLink.attach(self(), pid)
|
||||||
|
assert_receive {:blogmark_deep_link, ^url}, 500
|
||||||
|
end
|
||||||
|
|
||||||
|
test "replays multiple cold-start links in arrival order", %{pid: pid} do
|
||||||
|
send(pid, {:open_url, ~c"bds2://new-post?title=One"})
|
||||||
|
send(pid, {:open_url, ~c"bds2://new-post?title=Two"})
|
||||||
|
|
||||||
|
:ok = DeepLink.attach(self(), pid)
|
||||||
|
|
||||||
|
assert_receive {:blogmark_deep_link, "bds2://new-post?title=One"}, 500
|
||||||
|
assert_receive {:blogmark_deep_link, "bds2://new-post?title=Two"}, 500
|
||||||
|
end
|
||||||
|
|
||||||
|
test "queues again after the attached shell dies", %{pid: pid} do
|
||||||
|
task = Task.async(fn -> DeepLink.attach(self(), pid) end)
|
||||||
|
Task.await(task)
|
||||||
|
# task process is now dead; DeepLink should observe the DOWN and re-queue.
|
||||||
|
Process.sleep(50)
|
||||||
|
|
||||||
|
url = "bds2://new-post?title=After&url=https://example.com"
|
||||||
|
send(pid, {:open_url, String.to_charlist(url)})
|
||||||
|
|
||||||
|
:ok = DeepLink.attach(self(), pid)
|
||||||
|
assert_receive {:blogmark_deep_link, ^url}, 500
|
||||||
|
end
|
||||||
|
|
||||||
test "ignores non-bds2 open_url events", %{pid: pid} do
|
test "ignores non-bds2 open_url events", %{pid: pid} do
|
||||||
send(pid, {:open_url, ["https://example.com"]})
|
:ok = DeepLink.attach(self(), pid)
|
||||||
|
send(pid, {:open_url, ~c"https://example.com"})
|
||||||
|
|
||||||
refute_receive {:blogmark_deep_link, _url}, 200
|
refute_receive {:blogmark_deep_link, _url}, 200
|
||||||
end
|
end
|
||||||
|
|
||||||
test "ignores unrelated messages", %{pid: pid} do
|
test "ignores unrelated messages", %{pid: pid} do
|
||||||
|
:ok = DeepLink.attach(self(), pid)
|
||||||
send(pid, {:open_file, ["/tmp/x"]})
|
send(pid, {:open_file, ["/tmp/x"]})
|
||||||
|
|
||||||
refute_receive {:blogmark_deep_link, _url}, 200
|
refute_receive {:blogmark_deep_link, _url}, 200
|
||||||
|
|||||||
@@ -904,6 +904,59 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ ~s(data-tab-id="#{created_post.id}")
|
assert html =~ ~s(data-tab-id="#{created_post.id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "blogmark deep link with a project_id switches to that project and imports there",
|
||||||
|
%{project: active} do
|
||||||
|
{:ok, other} =
|
||||||
|
Projects.create_project(%{
|
||||||
|
name: "Other Blog",
|
||||||
|
data_path: Path.join(System.tmp_dir!(), "bds-other-#{System.unique_integer([:positive])}")
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
url =
|
||||||
|
"bds2://new-post?title=" <>
|
||||||
|
URI.encode_www_form("Cross Project") <>
|
||||||
|
"&url=" <> URI.encode_www_form("https://example.com/x") <>
|
||||||
|
"&project_id=" <> other.id
|
||||||
|
|
||||||
|
send(view.pid, {:blogmark_deep_link, url})
|
||||||
|
html = render(view)
|
||||||
|
|
||||||
|
created_post = Repo.get_by!(Post, title: "Cross Project")
|
||||||
|
# Imported into the link's project, not the one that was active.
|
||||||
|
assert created_post.project_id == other.id
|
||||||
|
refute created_post.project_id == active.id
|
||||||
|
# And the shell switched the active project to the link's target.
|
||||||
|
assert Projects.get_active_project().id == other.id
|
||||||
|
assert html =~ ~s(data-tab-id="#{created_post.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "blogmark deep link with an unknown project_id fails without importing", %{project: active} do
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
post_count_before = Repo.aggregate(Post, :count, :id)
|
||||||
|
|
||||||
|
url =
|
||||||
|
"bds2://new-post?title=" <>
|
||||||
|
URI.encode_www_form("Stale Link") <>
|
||||||
|
"&url=" <> URI.encode_www_form("https://example.com/s") <>
|
||||||
|
"&project_id=does-not-exist-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
send(view.pid, {:blogmark_deep_link, url})
|
||||||
|
_html = render(view)
|
||||||
|
|
||||||
|
# No post is created, and the active project is left untouched.
|
||||||
|
assert Repo.aggregate(Post, :count, :id) == post_count_before
|
||||||
|
assert Projects.get_active_project().id == active.id
|
||||||
|
|
||||||
|
# The user is told the targeted project does not exist (error toast entry).
|
||||||
|
entries = :sys.get_state(view.pid).socket.assigns.output_entries
|
||||||
|
|
||||||
|
assert Enum.any?(entries, fn entry ->
|
||||||
|
entry.level == "error" and entry.message =~ "does not exist here"
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
test "settings sidebar selections expose a scroll target for the preferences editor" do
|
test "settings sidebar selections expose a scroll target for the preferences editor" do
|
||||||
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user