fix: A1-17 route bds2://new-post deep links into transform pipeline and draft creation
This commit is contained in:
@@ -25,21 +25,22 @@ defmodule BDS.Application do
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
{Phoenix.PubSub, name: BDS.PubSub},
|
||||
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
|
||||
BDS.Repo,
|
||||
BDS.RepoBootstrap,
|
||||
BDS.Tasks,
|
||||
BDS.Preview,
|
||||
BDS.Publishing,
|
||||
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
|
||||
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
|
||||
BDS.Scripting.JobStore,
|
||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||
BDS.Scripting.JobSupervisor,
|
||||
BDS.Embeddings.Index
|
||||
] ++ embedding_children() ++ desktop_children(current_env())
|
||||
children =
|
||||
[
|
||||
{Phoenix.PubSub, name: BDS.PubSub},
|
||||
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
|
||||
BDS.Repo,
|
||||
BDS.RepoBootstrap,
|
||||
BDS.Tasks,
|
||||
BDS.Preview,
|
||||
BDS.Publishing,
|
||||
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
|
||||
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
|
||||
BDS.Scripting.JobStore,
|
||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||
BDS.Scripting.JobSupervisor,
|
||||
BDS.Embeddings.Index
|
||||
] ++ embedding_children() ++ desktop_children(current_env())
|
||||
|
||||
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
@@ -72,7 +73,8 @@ defmodule BDS.Application do
|
||||
|
||||
[
|
||||
{Desktop.Window, window_opts},
|
||||
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher)
|
||||
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher),
|
||||
{BDS.Desktop.DeepLink, []}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
150
lib/bds/blogmark.ex
Normal file
150
lib/bds/blogmark.ex
Normal file
@@ -0,0 +1,150 @@
|
||||
defmodule BDS.Blogmark do
|
||||
@moduledoc """
|
||||
Receives `bds2://new-post` blogmark deep links and turns them into draft posts
|
||||
(spec: script.allium `BlogmarkReceived`/`ExecuteTransform`,
|
||||
editor_settings.allium `BookmarkletCopy`).
|
||||
|
||||
The browser bookmarklet (`BDS.Scripting.Capabilities.AppShell`) navigates to a
|
||||
`bds2://new-post?title=&url=` URL. The desktop layer hands that URL here, where
|
||||
it is parsed into a post candidate, run through the enabled transform pipeline
|
||||
(`BDS.Scripts.Transforms`), and finally persisted as a draft post — defaulting
|
||||
the category from the project's `blogmark_category` setting when neither the
|
||||
link nor a transform supplied one.
|
||||
|
||||
The `bds2://` scheme deliberately differs from the legacy app's `bds://` scheme
|
||||
so the two installs do not fight over the same registration.
|
||||
"""
|
||||
|
||||
alias BDS.Metadata
|
||||
alias BDS.Posts
|
||||
alias BDS.Scripts.Transforms
|
||||
|
||||
@scheme "bds2"
|
||||
@new_post_action "new-post"
|
||||
|
||||
@type candidate :: %{required(String.t()) => term()}
|
||||
|
||||
@type receive_result :: %{
|
||||
post: Posts.Post.t(),
|
||||
toasts: [String.t()],
|
||||
errors: [%{slug: String.t() | nil, reason: term()}]
|
||||
}
|
||||
|
||||
@doc """
|
||||
Parses a `bds2://new-post` deep link into a post candidate map.
|
||||
|
||||
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
|
||||
`tags` and `categories`, or an error when the scheme or action is unsupported.
|
||||
"""
|
||||
@spec parse_deep_link(String.t()) ::
|
||||
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
|
||||
def parse_deep_link(url) when is_binary(url) do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: @scheme, host: @new_post_action, query: query} ->
|
||||
{:ok, candidate_from_query(query)}
|
||||
|
||||
%URI{scheme: @scheme} ->
|
||||
{:error, :unsupported_action}
|
||||
|
||||
%URI{scheme: nil} ->
|
||||
{:error, :invalid_url}
|
||||
|
||||
%URI{} ->
|
||||
{:error, :unsupported_scheme}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Receives a blogmark deep link for `project_id`: parses it, runs the transform
|
||||
pipeline, and creates a draft post from the resulting candidate.
|
||||
|
||||
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
|
||||
budget-enforced transform messages and `errors` records any failed transforms.
|
||||
"""
|
||||
@spec receive_deep_link(String.t(), String.t(), keyword()) ::
|
||||
{:ok, receive_result()} | {:error, term()}
|
||||
def receive_deep_link(project_id, url, opts \\ [])
|
||||
when is_binary(project_id) and is_binary(url) and is_list(opts) do
|
||||
with {:ok, candidate} <- parse_deep_link(url),
|
||||
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
|
||||
Transforms.run(project_id, candidate, opts),
|
||||
data <- apply_default_category(project_id, data),
|
||||
{:ok, post} <- create_draft(project_id, data) do
|
||||
{:ok, %{post: post, toasts: toasts, errors: errors}}
|
||||
end
|
||||
end
|
||||
|
||||
defp candidate_from_query(query) do
|
||||
params = URI.decode_query(query || "")
|
||||
|
||||
%{
|
||||
"title" => Map.get(params, "title", "") |> to_string(),
|
||||
"url" => optional(params, "url"),
|
||||
"content" => optional(params, "content"),
|
||||
"tags" => list_param(params, "tags"),
|
||||
"categories" => list_param(params, "categories")
|
||||
}
|
||||
end
|
||||
|
||||
defp optional(params, key) do
|
||||
case Map.get(params, key) do
|
||||
nil -> nil
|
||||
"" -> nil
|
||||
value -> value
|
||||
end
|
||||
end
|
||||
|
||||
defp list_param(params, key) do
|
||||
case Map.get(params, key) do
|
||||
value when is_binary(value) ->
|
||||
value
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Apply the project default category only when neither the deep link nor a
|
||||
# transform produced one, so explicit categories always win.
|
||||
defp apply_default_category(project_id, data) do
|
||||
case Map.get(data, "categories") do
|
||||
categories when is_list(categories) and categories != [] ->
|
||||
data
|
||||
|
||||
_ ->
|
||||
case default_category(project_id) do
|
||||
nil -> data
|
||||
category -> Map.put(data, "categories", [category])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp default_category(project_id) do
|
||||
case Metadata.get_project_metadata(project_id) do
|
||||
{:ok, %{blogmark_category: category}} when is_binary(category) and category != "" ->
|
||||
category
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp create_draft(project_id, data) do
|
||||
Posts.create_post(%{
|
||||
project_id: project_id,
|
||||
title: Map.get(data, "title", ""),
|
||||
content: optional_string(Map.get(data, "content")),
|
||||
tags: string_list(Map.get(data, "tags")),
|
||||
categories: string_list(Map.get(data, "categories"))
|
||||
})
|
||||
end
|
||||
|
||||
defp optional_string(value) when is_binary(value), do: value
|
||||
defp optional_string(_value), do: nil
|
||||
|
||||
defp string_list(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
|
||||
defp string_list(_other), do: []
|
||||
end
|
||||
74
lib/bds/desktop/deep_link.ex
Normal file
74
lib/bds/desktop/deep_link.ex
Normal file
@@ -0,0 +1,74 @@
|
||||
defmodule BDS.Desktop.DeepLink do
|
||||
@moduledoc """
|
||||
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
|
||||
shell (spec: script.allium `BlogmarkReceived`).
|
||||
|
||||
On macOS the app bundle registers `bds2://` as a custom URL scheme (see the
|
||||
`CFBundleURLTypes` entry in the packaged `Info.plist`). When the browser
|
||||
bookmarklet navigates to `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 events and forwards recognised `bds2://` links to
|
||||
the live shell over PubSub, where `BDS.Blogmark` turns them into draft posts.
|
||||
|
||||
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
|
||||
installs do not contend for the same registration.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.CliSync.Watcher
|
||||
|
||||
@scheme "bds2://"
|
||||
|
||||
def child_spec(opts) do
|
||||
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}}
|
||||
end
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
pubsub = Keyword.get(opts, :pubsub, BDS.PubSub)
|
||||
topic = Keyword.get(opts, :topic, Watcher.topic())
|
||||
|
||||
subscribe_to_env()
|
||||
|
||||
{:ok, %{pubsub: pubsub, topic: topic}}
|
||||
end
|
||||
|
||||
# Desktop.Env delivers OS events as {event_name, args} tuples.
|
||||
@impl true
|
||||
def handle_info({:open_url, [url | _rest]}, state) when is_binary(url) do
|
||||
{:noreply, route(url, state)}
|
||||
end
|
||||
|
||||
def handle_info(_message, state), do: {:noreply, state}
|
||||
|
||||
defp route(url, state) do
|
||||
if String.starts_with?(url, @scheme) do
|
||||
Phoenix.PubSub.broadcast(state.pubsub, state.topic, {:blogmark_deep_link, url})
|
||||
else
|
||||
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
|
||||
end
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
# Desktop.Env is only present when the wx desktop adapter is running. Guard the
|
||||
# subscribe so the GenServer can still start in headless/test configurations.
|
||||
defp subscribe_to_env do
|
||||
if Process.whereis(Desktop.Env) do
|
||||
try do
|
||||
Desktop.Env.subscribe()
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
@@ -5,7 +5,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|
||||
import Phoenix.HTML
|
||||
|
||||
alias BDS.{AI, BoundedAtoms, Metadata}
|
||||
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
|
||||
alias BDS.CliSync.Watcher
|
||||
alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
|
||||
|
||||
@@ -717,6 +717,10 @@ defmodule BDS.Desktop.ShellLive do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:blogmark_deep_link, url}, socket) when is_binary(url) do
|
||||
{:noreply, handle_blogmark_deep_link(socket, url)}
|
||||
end
|
||||
|
||||
def handle_info(message, socket) do
|
||||
Bridges.handle_info(message, socket, bridges_callbacks())
|
||||
end
|
||||
@@ -1002,6 +1006,56 @@ defmodule BDS.Desktop.ShellLive do
|
||||
defp create_sidebar_item(socket, kind),
|
||||
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
|
||||
|
||||
# Receive a bds2://new-post blogmark deep link: run the transform pipeline,
|
||||
# create a draft post, open it in the editor, and surface transform toasts.
|
||||
defp handle_blogmark_deep_link(socket, url) do
|
||||
title = dgettext("ui", "Blogmark")
|
||||
|
||||
case current_project_id(socket) do
|
||||
project_id when is_binary(project_id) ->
|
||||
case Blogmark.receive_deep_link(project_id, url) do
|
||||
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
|
||||
socket
|
||||
|> reload_shell(socket.assigns.workbench)
|
||||
|> open_sidebar_item(
|
||||
%{
|
||||
"route" => "post",
|
||||
"id" => post.id,
|
||||
"title" => post.title,
|
||||
"subtitle" => post.slug
|
||||
},
|
||||
:pin
|
||||
)
|
||||
|> append_blogmark_toasts(title, toasts)
|
||||
|> append_blogmark_errors(title, errors)
|
||||
|
||||
{:error, reason} ->
|
||||
append_output_entry(socket, title, inspect(reason), url, "error")
|
||||
end
|
||||
|
||||
_ ->
|
||||
append_output_entry(
|
||||
socket,
|
||||
title,
|
||||
dgettext("ui", "Open a project before importing a blogmark."),
|
||||
url,
|
||||
"warning"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp append_blogmark_toasts(socket, title, toasts) do
|
||||
Enum.reduce(toasts, socket, fn message, acc ->
|
||||
append_output_entry(acc, title, message, nil, "info")
|
||||
end)
|
||||
end
|
||||
|
||||
defp append_blogmark_errors(socket, title, errors) do
|
||||
Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc ->
|
||||
append_output_entry(acc, title, inspect(reason), slug, "error")
|
||||
end)
|
||||
end
|
||||
|
||||
defp handle_file_picker_result(socket, {:ok, _media}),
|
||||
do: refresh_content(socket, socket.assigns.workbench)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user