134 lines
4.5 KiB
Elixir
134 lines
4.5 KiB
Elixir
defmodule BDS.Scripts.Transforms do
|
|
@moduledoc """
|
|
Runs the blogmark transform pipeline (spec: script.allium `ExecuteTransform`).
|
|
|
|
Enabled `transform` scripts for a project are applied sequentially to a post
|
|
candidate produced by a `bds2://new-post` blogmark deep link. Each transform
|
|
receives the current candidate plus a context describing the blogmark source
|
|
and origin URL, and returns the modified candidate.
|
|
|
|
Guarantees enforced here:
|
|
|
|
* `TransformTrigger` — each script receives the candidate plus
|
|
`{source = "blogmark", url = ...}` context.
|
|
* `TransformPipelineContinuation` — a transform error is captured per script
|
|
and does not roll back the last valid candidate; the pipeline continues.
|
|
* `TransformToastBudget` — at most `transform_max_toasts_per_script` toasts
|
|
are accepted from any one transform, with a total budget of
|
|
`transform_max_toasts_total`, each truncated to
|
|
`transform_max_toast_length` characters.
|
|
"""
|
|
|
|
require Logger
|
|
|
|
alias BDS.Scripts
|
|
alias BDS.Scripts.Script
|
|
alias BDS.Scripting
|
|
alias BDS.Scripting.Capabilities.Util
|
|
|
|
@type data :: %{optional(String.t()) => term()}
|
|
@type result :: %{
|
|
data: data(),
|
|
toasts: [String.t()],
|
|
errors: [%{slug: String.t() | nil, reason: term()}]
|
|
}
|
|
|
|
@doc """
|
|
Applies every enabled transform script for `project_id` to `data` in order.
|
|
|
|
Returns `{:ok, %{data:, toasts:, errors:}}` where `data` is the final
|
|
candidate, `toasts` are the budget-enforced messages accepted across the
|
|
pipeline, and `errors` records any transforms that failed.
|
|
"""
|
|
@spec run(String.t(), data(), keyword()) :: {:ok, result()}
|
|
def run(project_id, data, opts \\ [])
|
|
when is_binary(project_id) and is_map(data) and is_list(opts) do
|
|
context = %{"source" => "blogmark", "url" => Map.get(data, "url")}
|
|
transforms = Scripts.list_transform_scripts(project_id)
|
|
|
|
initial = %{data: data, toasts: [], errors: [], toast_total: 0}
|
|
|
|
final =
|
|
Enum.reduce(transforms, initial, fn script, acc ->
|
|
apply_transform(project_id, script, context, acc, opts)
|
|
end)
|
|
|
|
{:ok,
|
|
%{data: final.data, toasts: Enum.reverse(final.toasts), errors: Enum.reverse(final.errors)}}
|
|
end
|
|
|
|
defp apply_transform(_project_id, %Script{entrypoint: entry}, _context, acc, _opts)
|
|
when entry in [nil, ""] do
|
|
acc
|
|
end
|
|
|
|
defp apply_transform(project_id, %Script{} = script, context, acc, opts) do
|
|
source = Scripts.resolved_content(script)
|
|
|
|
case Scripting.execute_project_script(
|
|
project_id,
|
|
source,
|
|
script.entrypoint,
|
|
[acc.data, context],
|
|
opts
|
|
) do
|
|
{:ok, returned} ->
|
|
{next_data, raw_toasts} = split_return(Util.normalize_input(returned), acc.data)
|
|
accept_toasts(%{acc | data: next_data}, raw_toasts)
|
|
|
|
{:error, reason} ->
|
|
Logger.warning("transform #{script.slug} failed: #{inspect(reason)}")
|
|
%{acc | errors: [%{slug: script.slug, reason: reason} | acc.errors]}
|
|
end
|
|
end
|
|
|
|
# A transform may return either the candidate map directly, or a wrapper
|
|
# `{ data = <candidate>, toasts = [...] }`. Blogmark candidates never carry a
|
|
# nested "data" map, so the wrapper shape is unambiguous.
|
|
defp split_return(%{"data" => %{} = inner} = wrapper, _previous) do
|
|
{inner, toast_list(Map.get(wrapper, "toasts"))}
|
|
end
|
|
|
|
defp split_return(returned, _previous) when is_map(returned), do: {returned, []}
|
|
defp split_return(_returned, previous), do: {previous, []}
|
|
|
|
defp toast_list(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
|
|
defp toast_list(_other), do: []
|
|
|
|
defp accept_toasts(acc, raw_toasts) do
|
|
per_script_max = config(:transform_max_toasts_per_script, 5)
|
|
total_max = config(:transform_max_toasts_total, 20)
|
|
max_length = config(:transform_max_toast_length, 300)
|
|
|
|
raw_toasts
|
|
|> Enum.take(per_script_max)
|
|
|> Enum.reduce(acc, fn message, inner_acc ->
|
|
if inner_acc.toast_total >= total_max do
|
|
inner_acc
|
|
else
|
|
truncated = truncate(message, max_length)
|
|
|
|
%{
|
|
inner_acc
|
|
| toasts: [truncated | inner_acc.toasts],
|
|
toast_total: inner_acc.toast_total + 1
|
|
}
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp truncate(message, max_length) do
|
|
if String.length(message) > max_length do
|
|
String.slice(message, 0, max_length)
|
|
else
|
|
message
|
|
end
|
|
end
|
|
|
|
defp config(key, default) do
|
|
:bds
|
|
|> Application.fetch_env!(:scripting)
|
|
|> Keyword.get(key, default)
|
|
end
|
|
end
|