Compare commits

...

40 Commits

Author SHA1 Message Date
cea47f10e2 feat: ccloseout of changes for audit 2026-06-22 11:17:03 +02:00
e1abe457e8 feat: close out of AUDIT implementation 2026-06-22 11:16:47 +02:00
562eed2b98 Replace abandoned earmark with mdex for markdown rendering 2026-06-21 16:07:44 +02:00
7c38061c1c Bump earmark and serialize test suite to clear validation gate 2026-06-21 15:46:16 +02:00
67a1f8a5f0 Replace exact blank_to_nil helpers 2026-06-21 14:31:29 +02:00
d3c21247e0 Replace local maybe_put helpers 2026-06-21 14:30:40 +02:00
cd4a01a2c8 Record wrapper audit verdicts 2026-06-21 14:28:58 +02:00
b93b650689 Inline desktop external links 2026-06-21 14:28:31 +02:00
e284f030ae Fold scripting runtime into Lua 2026-06-21 14:27:15 +02:00
4c9c432d33 Delete empty code entity editor 2026-06-21 14:26:20 +02:00
64ca5d4637 Use Bandit child spec directly 2026-06-21 14:26:04 +02:00
a89175e230 Use Agent for job store 2026-06-21 14:25:32 +02:00
26f06f3298 Optimize bounded atom lookups 2026-06-21 14:25:00 +02:00
6a7a8c4288 Replace local attr helpers 2026-06-21 14:24:05 +02:00
62f87b8ab4 Record automation audit verdict 2026-06-21 14:22:40 +02:00
ff777e590f Dispatch sidebar filter events 2026-06-21 14:22:20 +02:00
7f1dbc390c Merge scripting callback wrappers 2026-06-21 14:21:15 +02:00
59c236d871 Simplify secret backend fallback 2026-06-21 14:20:08 +02:00
c3a8a8a755 Simplify secret backend fallback 2026-06-21 14:15:55 +02:00
2e61059568 Simplify secret key fallbacks 2026-06-21 14:10:00 +02:00
936a20a03e Deduplicate one-shot request flow 2026-06-21 14:06:50 +02:00
0da149e105 Merge AI runtime request paths 2026-06-21 14:03:41 +02:00
dc6fc4c631 Simplify chat tool round flow 2026-06-21 13:54:35 +02:00
d3e2397217 Deduplicate chat tool query patterns 2026-06-21 13:51:44 +02:00
3b14c1ecf3 Simplify MCP HTTP responses 2026-06-21 13:24:47 +02:00
bb58f98afd Deduplicate MCP tool descriptors 2026-06-21 13:22:12 +02:00
66c24b565f Unify generation render fallbacks 2026-06-21 13:17:44 +02:00
ccfaa4afe6 Deduplicate generation path prefixes 2026-06-21 13:15:02 +02:00
f46239188a Extract generation snapshot loaders 2026-06-21 13:12:17 +02:00
4a8a420c62 Deduplicate sitemap URL entry builders 2026-06-21 13:09:35 +02:00
b3d69f0291 Consolidate generation route builders 2026-06-21 13:06:50 +02:00
f8f6a242d6 Deduplicate validation timestamp checks 2026-06-21 13:04:13 +02:00
341c1bf8ee Simplify rendering path builders 2026-06-21 13:00:51 +02:00
4f6f7a6e1e Merge metadata menu item builders 2026-06-21 12:58:27 +02:00
72f6cc7d97 Simplify template selection queries 2026-06-21 12:55:57 +02:00
f52e33760e Simplify list archive fallbacks 2026-06-21 12:53:41 +02:00
e87c35e256 Simplify post rendering fallbacks 2026-06-21 12:51:25 +02:00
2d98780007 Deduplicate macro rendering paths 2026-06-21 12:45:23 +02:00
26c481ea1f Record liquid parser audit verdict 2026-06-21 12:42:38 +02:00
65d89a198e Refactor localized month labels 2026-06-21 12:42:23 +02:00
55 changed files with 1166 additions and 1283 deletions

View File

@@ -5,7 +5,10 @@ config :bds, BDS.Repo,
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 5,
journal_mode: :wal,
busy_timeout: 15_000
# Desktop/LiveView tests can leave short-lived reader/writer processes
# draining after assertions. Give SQLite more time to wait out those locks
# during serialized suite runs instead of failing the next test setup.
busy_timeout: 60_000
config :logger, level: :warning

View File

@@ -578,47 +578,22 @@ defmodule BDS.AI.Chat do
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, generate_opts),
{:ok, assistant_message} <- persist_assistant_response(conversation.id, response),
:ok <- touch_conversation(conversation.id) do
if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do
notify_chat_event(
opts,
{:chat_streaming_content, conversation.id, Map.get(response, :content)}
)
end
notify_assistant_content(response, conversation.id, opts)
tool_calls = decode_tool_calls(Map.get(response, :tool_calls))
Enum.each(tool_calls, fn tool_call ->
notify_chat_event(opts, {:chat_tool_call, conversation.id, tool_call})
end)
cond do
tool_calls != [] and tools != [] ->
with {:ok, tool_messages} <-
execute_tool_calls(conversation.id, tool_calls, project_id, opts),
updated_messages <- load_chat_messages(conversation.id),
{:ok, reply} <-
chat_round(
Repo.get!(ChatConversation, conversation.id),
updated_messages,
endpoint,
model,
project_id,
tools,
runtime,
opts,
rounds_left - 1
) do
{:ok, Map.put(reply, :tool_messages, tool_messages)}
end
true ->
{:ok,
%{
conversation: format_conversation(Repo.get!(ChatConversation, conversation.id)),
assistant_message: format_chat_message(assistant_message),
tool_messages: []
}}
end
response
|> Map.get(:tool_calls)
|> decode_tool_calls()
|> continue_chat_round(
conversation.id,
assistant_message,
endpoint,
model,
project_id,
tools,
runtime,
opts,
rounds_left
)
end
end
@@ -673,6 +648,63 @@ defmodule BDS.AI.Chat do
{:ok, tool_messages}
end
defp continue_chat_round(
tool_calls,
conversation_id,
assistant_message,
endpoint,
model,
project_id,
tools,
runtime,
opts,
rounds_left
) do
Enum.each(tool_calls, fn tool_call ->
notify_chat_event(opts, {:chat_tool_call, conversation_id, tool_call})
end)
if tool_calls != [] and tools != [] do
with {:ok, tool_messages} <- execute_tool_calls(conversation_id, tool_calls, project_id, opts),
updated_messages <- load_chat_messages(conversation_id),
{:ok, reply} <-
chat_round(
Repo.get!(ChatConversation, conversation_id),
updated_messages,
endpoint,
model,
project_id,
tools,
runtime,
opts,
rounds_left - 1
) do
{:ok, Map.put(reply, :tool_messages, tool_messages)}
end
else
final_chat_reply(conversation_id, assistant_message)
end
end
defp final_chat_reply(conversation_id, assistant_message) do
{:ok,
%{
conversation: format_conversation(Repo.get!(ChatConversation, conversation_id)),
assistant_message: format_chat_message(assistant_message),
tool_messages: []
}}
end
defp notify_assistant_content(response, conversation_id, opts) do
content = Map.get(response, :content)
if is_binary(content) and String.trim(content) != "" do
notify_chat_event(opts, {:chat_streaming_content, conversation_id, content})
end
:ok
end
defp build_chat_request(conversation, messages, model, project_id, tools) do
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id, tools)}

View File

@@ -59,16 +59,7 @@ defmodule BDS.AI.ChatTools do
def execute("search_posts", arguments, project_id) do
project_id = project_id || active_project_id()
filters = search_filters(arguments)
{:ok, result} = Search.search_posts(project_id, arguments["query"] || "", filters)
%{
posts: Enum.map(result.posts, &Queries.post_summary/1),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
search_result(project_id, arguments["query"] || "", filters, &Queries.post_summary/1)
end
def execute("read_post_by_slug", arguments, project_id) do
@@ -83,13 +74,9 @@ defmodule BDS.AI.ChatTools do
def execute("read_post", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"],
project_id: project_id
) do
%Post{} = post -> %{post: Queries.post_detail(post)}
nil -> %{success: false, error: "not_found"}
end
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
%{post: Queries.post_detail(post)}
end)
end
def execute("list_posts", arguments, project_id) do
@@ -98,21 +85,12 @@ defmodule BDS.AI.ChatTools do
offset = normalize_offset(arguments["offset"])
filters = search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
{:ok, result} = Search.search_posts(project_id, "", filters)
%{
posts:
Enum.map(result.posts, fn post ->
post
|> Queries.post_summary()
|> Map.put("url", "/posts/#{post.slug}")
|> Map.put("updated_at", post.updated_at)
end),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
search_result(project_id, "", filters, fn post ->
post
|> Queries.post_summary()
|> Map.put("url", "/posts/#{post.slug}")
|> Map.put("updated_at", post.updated_at)
end)
end
def execute("list_media", arguments, project_id) do
@@ -138,13 +116,9 @@ defmodule BDS.AI.ChatTools do
def execute("get_media", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Media,
id: arguments["mediaId"] || arguments["media_id"],
project_id: project_id
) do
%Media{} = media -> %{media: media_summary(media)}
nil -> %{success: false, error: "not_found"}
end
with_media(arguments, project_id, %{success: false, error: "not_found"}, fn media ->
%{media: media_summary(media)}
end)
end
def execute("view_image", arguments, project_id) do
@@ -235,51 +209,33 @@ defmodule BDS.AI.ChatTools do
def execute("get_post_backlinks", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"],
project_id: project_id
) do
%Post{} = post ->
%{success: true, post_id: post.id, linked_by: Queries.linked_posts(post.id, :incoming)}
nil ->
%{success: false, error: "not_found"}
end
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
%{success: true, post_id: post.id, linked_by: Queries.linked_posts(post.id, :incoming)}
end)
end
def execute("get_post_outlinks", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"],
project_id: project_id
) do
%Post{} = post ->
%{success: true, post_id: post.id, links_to: Queries.linked_posts(post.id, :outgoing)}
nil ->
%{success: false, error: "not_found"}
end
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
%{success: true, post_id: post.id, links_to: Queries.linked_posts(post.id, :outgoing)}
end)
end
def execute("get_post_media", arguments, project_id) do
project_id = project_id || active_project_id()
post_id = arguments["postId"] || arguments["post_id"]
case Repo.get_by(Post, id: post_id, project_id: project_id) do
%Post{} = post -> %{success: true, post_id: post.id, media: post_media(project_id, post.id)}
nil -> %{success: false, error: "not_found"}
end
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
%{success: true, post_id: post.id, media: post_media(project_id, post.id)}
end)
end
def execute("get_media_posts", arguments, project_id) do
project_id = project_id || active_project_id()
media_id = arguments["mediaId"] || arguments["media_id"]
case Repo.get_by(Media, id: media_id, project_id: project_id) do
%Media{} = media -> %{success: true, media_id: media.id, posts: media_posts(media.id)}
nil -> %{success: false, error: "not_found"}
end
with_media(arguments, project_id, %{success: false, error: "not_found"}, fn media ->
%{success: true, media_id: media.id, posts: media_posts(media.id)}
end)
end
def execute("render_table", arguments, _project_id) do
@@ -986,13 +942,16 @@ defmodule BDS.AI.ChatTools do
defp search_filters(arguments) do
%{}
|> maybe_put(:category, arguments["category"])
|> maybe_put(:tags, arguments["tags"])
|> maybe_put(:language, arguments["language"])
|> maybe_put(:missing_translation_language, arguments["missingTranslationLanguage"])
|> maybe_put(:year, arguments["year"])
|> maybe_put(:month, arguments["month"])
|> maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"]))
|> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"]))
|> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"]))
|> BDS.MapUtils.maybe_put(
:missing_translation_language,
BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"])
)
|> BDS.MapUtils.maybe_put(:year, arguments["year"])
|> BDS.MapUtils.maybe_put(:month, arguments["month"])
|> BDS.MapUtils.maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|> Map.put(:limit, normalize_limit(arguments["limit"]))
end
@@ -1007,9 +966,37 @@ defmodule BDS.AI.ChatTools do
result
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp search_result(project_id, query, filters, mapper) do
{:ok, result} = Search.search_posts(project_id, query, filters)
%{
posts: Enum.map(result.posts, mapper),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end
defp with_post(arguments, project_id, not_found_result, success_fun) do
case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"],
project_id: project_id
) do
%Post{} = post -> success_fun.(post)
nil -> not_found_result
end
end
defp with_media(arguments, project_id, not_found_result, success_fun) do
case Repo.get_by(Media,
id: arguments["mediaId"] || arguments["media_id"],
project_id: project_id
) do
%Media{} = media -> success_fun.(media)
nil -> not_found_result
end
end
defp counted_terms(project_id, field) do
Repo.all(
@@ -1024,7 +1011,11 @@ defmodule BDS.AI.ChatTools do
defp metadata_attrs(arguments, keys) do
Enum.reduce(keys, %{}, fn key, acc ->
maybe_put(acc, BDS.MapUtils.safe_atomize_key(key), arguments[key])
BDS.MapUtils.maybe_put(
acc,
BDS.MapUtils.safe_atomize_key(key),
BDS.MapUtils.blank_to_nil(arguments[key])
)
end)
end

View File

@@ -167,28 +167,13 @@ defmodule BDS.AI.OneShot do
end
end
defp run_one_shot(:analyze_image = operation, payload, opts, formatter) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode),
{:ok, payload} <- resolve_image_data_url(payload),
request <- build_one_shot_request(operation, payload, model, opts),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response),
usage <- Chat.normalize_usage(response.usage),
{:ok, result} <- formatter.(json, usage) do
{:ok, result}
end
end
defp run_one_shot(operation, payload, opts, formatter) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode),
request <- build_one_shot_request(operation, payload, model, opts),
{:ok, prepared_payload} <- prepare_one_shot_payload(operation, payload),
request <- build_one_shot_request(operation, prepared_payload, model, opts),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response),
@@ -198,6 +183,9 @@ defmodule BDS.AI.OneShot do
end
end
defp prepare_one_shot_payload(:analyze_image, payload), do: resolve_image_data_url(payload)
defp prepare_one_shot_payload(_operation, payload), do: {:ok, payload}
defp build_one_shot_request(operation, payload, model, opts) do
language = Keyword.get(opts, :language)

View File

@@ -10,9 +10,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
http_client = Keyword.get(opts, :http_client, HttpClient)
url = models_url(endpoint.url)
headers =
%{"accept" => "application/json"}
|> maybe_put_auth(endpoint.api_key)
headers = request_headers(endpoint.api_key, false)
with {:ok, response} <- http_client.get(url, headers),
200 <- response.status do
@@ -26,21 +24,8 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
def generate(endpoint, request, opts) when is_map(endpoint) and is_map(request) do
url = completions_url(endpoint.url)
headers =
%{
"content-type" => "application/json",
"accept" => "application/json"
}
|> maybe_put_auth(endpoint.api_key)
payload =
%{
"model" => request.model,
"messages" => request.messages,
"max_tokens" => request.max_output_tokens
}
|> maybe_disable_thinking(request.model)
|> maybe_put_tools(Map.get(request, :tools, []))
headers = request_headers(endpoint.api_key, true)
payload = base_payload(request)
if stream?(request, opts) do
generate_streaming(url, headers, payload, request, Keyword.fetch!(opts, :on_stream))
@@ -78,15 +63,12 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
result
{:ok, %{status: status, body: body}} ->
Logger.error(
"AI OpenAI-compatible HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}"
)
{:error, %{kind: :http_error, status: status, body: body}}
log_http_status_error("AI OpenAI-compatible", status, body)
http_status_error(status, body)
{:error, reason} ->
Logger.error("AI OpenAI-compatible HTTP request failed: #{inspect(reason)}")
{:error, %{kind: :http_error, reason: reason}}
log_http_request_error("AI OpenAI-compatible", reason)
http_request_error(reason)
end
end
@@ -95,11 +77,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
# snapshots to `on_stream` as they arrive. The assembled message goes
# through the same normalization as the blocking path.
defp generate_streaming(url, headers, payload, request, on_stream) do
payload_json =
payload
|> Map.put("stream", true)
|> Map.put("stream_options", %{"include_usage" => true})
|> Jason.encode!()
payload_json = payload |> streaming_payload() |> Jason.encode!()
Logger.debug(
"AI OpenAI-compatible streaming request operation=#{inspect(Map.get(request, :operation))} model=#{inspect(request.model)} url=#{url} payload_size=#{byte_size(payload_json)}"
@@ -127,15 +105,12 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
end
{:ok, %{status: status, body: body}, _sse} ->
Logger.error(
"AI OpenAI-compatible streaming HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}"
)
{:error, %{kind: :http_error, status: status, body: body}}
log_http_status_error("AI OpenAI-compatible streaming", status, body)
http_status_error(status, body)
{:error, reason} ->
Logger.error("AI OpenAI-compatible streaming request failed: #{inspect(reason)}")
{:error, %{kind: :http_error, reason: reason}}
log_http_request_error("AI OpenAI-compatible streaming", reason)
http_request_error(reason)
end
end
@@ -236,6 +211,44 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
end
end
defp request_headers(api_key, include_content_type?) do
%{"accept" => "application/json"}
|> maybe_put_content_type(include_content_type?)
|> maybe_put_auth(api_key)
end
defp maybe_put_content_type(headers, true),
do: Map.put(headers, "content-type", "application/json")
defp maybe_put_content_type(headers, false), do: headers
defp base_payload(request) do
%{
"model" => request.model,
"messages" => request.messages,
"max_tokens" => request.max_output_tokens
}
|> maybe_disable_thinking(request.model)
|> maybe_put_tools(Map.get(request, :tools, []))
end
defp streaming_payload(payload) do
payload
|> Map.put("stream", true)
|> Map.put("stream_options", %{"include_usage" => true})
end
defp log_http_status_error(prefix, status, body) do
Logger.error("#{prefix} HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}")
end
defp log_http_request_error(prefix, reason) do
Logger.error("#{prefix} request failed: #{inspect(reason)}")
end
defp http_status_error(status, body), do: {:error, %{kind: :http_error, status: status, body: body}}
defp http_request_error(reason), do: {:error, %{kind: :http_error, reason: reason}}
defp maybe_put_auth(headers, nil), do: headers
defp maybe_put_auth(headers, ""), do: headers

View File

@@ -41,10 +41,7 @@ defmodule BDS.AI.SecretBackend do
@spec decrypt(String.t()) :: {:ok, String.t()} | {:error, term()}
def decrypt(encoded) when is_binary(encoded) do
with {:ok, key} <- secret_key() do
case decrypt_with(encoded, key) do
{:ok, plaintext} -> {:ok, plaintext}
{:error, :invalid_ciphertext} -> decrypt_legacy(encoded)
end
decrypt_with_fallback(encoded, key)
end
end
@@ -79,6 +76,13 @@ defmodule BDS.AI.SecretBackend do
]
end
defp decrypt_with_fallback(encoded, key) do
case decrypt_with(encoded, key) do
{:ok, _plaintext} = ok -> ok
{:error, :invalid_ciphertext} -> decrypt_legacy(encoded)
end
end
defp decrypt_with(encoded, key) do
with {:ok, binary} <- Base.decode64(encoded),
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,

View File

@@ -105,11 +105,7 @@ defmodule BDS.AI.SecretKey do
keychain_create()
{:error, reason} ->
Logger.warning(
"AI secret key: macOS Keychain unavailable (#{inspect(reason)}); falling back to the key file"
)
resolve_file()
fallback_to_key_file("AI secret key: macOS Keychain unavailable", inspect(reason))
end
end
@@ -157,12 +153,10 @@ defmodule BDS.AI.SecretKey do
{:ok, key}
{output, status} ->
Logger.warning(
"AI secret key: could not store the key in the Keychain " <>
"(status #{status}: #{String.trim(output)}); falling back to the key file"
fallback_to_key_file(
"AI secret key: could not store the key in the Keychain",
"status #{status}: #{String.trim(output)}"
)
resolve_file()
end
end
@@ -230,4 +224,9 @@ defmodule BDS.AI.SecretKey do
defp config(key, default) do
Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
end
defp fallback_to_key_file(prefix, detail) do
Logger.warning(prefix <> " (" <> detail <> "); falling back to the key file")
resolve_file()
end
end

View File

@@ -67,6 +67,8 @@ defmodule BDS.BoundedAtoms do
%{id: id} -> [id]
end)
end)
@sidebar_view_ids Enum.map(Registry.sidebar_views(), & &1.id)
@editor_route_ids Enum.map(Registry.editor_routes(), & &1.id)
def atom(value, allowed, fallback \\ nil)
@@ -101,13 +103,9 @@ defmodule BDS.BoundedAtoms do
Enum.find(allowed, fallback, &(Atom.to_string(&1) == value))
end
defp sidebar_views do
Enum.map(Registry.sidebar_views(), & &1.id)
end
defp sidebar_views, do: @sidebar_view_ids
defp editor_routes do
Enum.map(Registry.editor_routes(), & &1.id)
end
defp editor_routes, do: @editor_route_ids
defp normalize_menu_kind("category-archive"), do: "category_archive"
defp normalize_menu_kind(value), do: value

View File

@@ -1,12 +0,0 @@
defmodule BDS.Desktop.ExternalLinks do
@moduledoc false
@github_url "https://github.com/rfc1437/bDS2"
@github_issues_url "#{@github_url}/issues"
@spec github_url() :: String.t()
def github_url, do: @github_url
@spec github_issues_url() :: String.t()
def github_issues_url, do: @github_issues_url
end

View File

@@ -2,7 +2,7 @@ defmodule BDS.Desktop.MenuBar do
@moduledoc false
use BDS.Desktop.MenuCompat
alias BDS.Desktop.{ExternalLinks, ShellData, Shutdown, UILocale}
alias BDS.Desktop.{ShellData, Shutdown, UILocale}
alias BDS.UI.Commands
alias BDS.UI.MenuBar, as: ShellMenuBar
alias Desktop.OS
@@ -59,7 +59,7 @@ defmodule BDS.Desktop.MenuBar do
end
def handle_event("view_on_github", menu) do
OS.launch_default_browser(ExternalLinks.github_url())
OS.launch_default_browser("https://github.com/rfc1437/bDS2")
{:noreply, menu}
end
@@ -78,7 +78,7 @@ defmodule BDS.Desktop.MenuBar do
end
def handle_event("report_issue", menu) do
OS.launch_default_browser(ExternalLinks.github_issues_url())
OS.launch_default_browser("https://github.com/rfc1437/bDS2/issues")
{:noreply, menu}
end

View File

@@ -1,17 +1,18 @@
defmodule BDS.Desktop.Server do
@moduledoc false
use GenServer
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]}
}
end
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
Supervisor.child_spec(
{Bandit,
Keyword.merge(opts,
plug: BDS.Desktop.Endpoint,
scheme: :http,
ip: {127, 0, 0, 1},
port: port(),
startup_log: false
)},
id: __MODULE__
)
end
def url do
@@ -24,18 +25,4 @@ defmodule BDS.Desktop.Server do
_other -> Application.get_env(:bds, :desktop)[:port] || 4010
end
end
@impl true
def init(_opts) do
{:ok, bandit_pid} =
Bandit.start_link(
plug: BDS.Desktop.Endpoint,
scheme: :http,
ip: {127, 0, 0, 1},
port: port(),
startup_log: false
)
{:ok, %{bandit_pid: bandit_pid}}
end
end

View File

@@ -7,7 +7,7 @@ defmodule BDS.Desktop.ShellLive do
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
alias BDS.CliSync.Watcher
alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData, UILocale}
alias BDS.Desktop.ShellLive.{
Bridges,
@@ -1326,12 +1326,12 @@ defmodule BDS.Desktop.ShellLive do
end
defp handle_socket_menu_action(socket, :view_on_github) do
OS.launch_default_browser(ExternalLinks.github_url())
OS.launch_default_browser("https://github.com/rfc1437/bDS2")
socket
end
defp handle_socket_menu_action(socket, :report_issue) do
OS.launch_default_browser(ExternalLinks.github_issues_url())
OS.launch_default_browser("https://github.com/rfc1437/bDS2/issues")
socket
end

View File

@@ -545,10 +545,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
@spec markdown_html(binary()) :: Phoenix.HTML.Safe.t()
def markdown_html(content) when is_binary(content) do
# Match Earmark's defaults (GFM tables, strikethrough, autolinks); escape
# raw HTML to text rather than rendering it, as the old escape: true did.
html =
case Earmark.as_html(content, escape: true) do
{:ok, rendered, _messages} -> rendered
{:error, rendered, _messages} -> rendered
case MDEx.to_html(content,
extension: [table: true, strikethrough: true, autolink: true],
render: [escape: true]
) do
{:ok, rendered} -> rendered
{:error, _reason} -> ""
end
|> rewrite_external_images()

View File

@@ -327,7 +327,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|> assign(:taxonomy_edit, %{
type: type,
name: name,
value: mapped_to |> to_string() |> blank_to_nil()
value: mapped_to |> to_string() |> BDS.MapUtils.blank_to_nil()
})
|> build_data()
@@ -693,7 +693,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
wxr_file_path: analysis_state.file_path,
last_analysis_result: report
}
|> maybe_put(:name, AnalysisState.suggested_definition_name(report))
|> BDS.MapUtils.maybe_put(:name, AnalysisState.suggested_definition_name(report))
case ImportDefinitions.update_definition(definition_id, attrs) do
{:ok, _definition} ->
@@ -1451,9 +1451,4 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
defp present?(value), do: value not in [nil, ""]
defp blank?(value), do: value in [nil, ""]
defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end

View File

@@ -160,7 +160,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
wxr_file_path: analysis_state.file_path,
last_analysis_result: report
}
|> maybe_put(:name, suggested_definition_name(report))
|> BDS.MapUtils.maybe_put(:name, suggested_definition_name(report))
case ImportDefinitions.update_definition(definition_id, attrs) do
{:ok, _definition} ->
@@ -274,10 +274,6 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
get_in(report, [:site_info, :url]) || get_in(report, [:site_info, :title])
end
@spec maybe_put(term(), term(), term()) :: term()
def maybe_put(map, _key, nil), do: map
def maybe_put(map, key, value), do: Map.put(map, key, value)
@spec allow_repo_sandbox(term()) :: term()
def allow_repo_sandbox(pid) when is_pid(pid) do
if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do

View File

@@ -17,7 +17,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
Map.put(socket.assigns.import_editor_taxonomy_edits, definition_id, %{
type: type,
name: name,
value: mapped_to |> to_string() |> blank_to_nil()
value: mapped_to |> to_string() |> BDS.MapUtils.blank_to_nil()
})
)
|> reload.(socket.assigns.workbench)
@@ -150,7 +150,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
@spec update_taxonomy_mapping(term(), term(), term(), term()) :: term()
def update_taxonomy_mapping(report, type, name, mapped_to) do
bucket_key = if(type == "categories", do: :categories, else: :tags)
normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil()
normalized_value = mapped_to |> to_string() |> String.trim() |> BDS.MapUtils.blank_to_nil()
updated_report =
update_in(report, [:items, bucket_key], fn items ->
@@ -229,7 +229,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
@spec normalize_taxonomy_mapping_value(term(), term(), term()) :: term()
def normalize_taxonomy_mapping_value(project_id, type, mapped_to) do
normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil()
normalized_value = mapped_to |> to_string() |> String.trim() |> BDS.MapUtils.blank_to_nil()
case normalized_value do
nil ->
@@ -285,6 +285,4 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
defp present?(value), do: value not in [nil, ""]
defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value
end

View File

@@ -278,10 +278,15 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
@spec markdown_html(String.t()) :: Phoenix.HTML.safe()
def markdown_html(content) do
# Match Earmark's defaults (GFM tables, strikethrough, autolinks); escape
# raw HTML to text rather than rendering it, as the old escape: true did.
html =
case Earmark.as_html(content || "", escape: true) do
{:ok, rendered, _messages} -> rendered
{:error, rendered, _messages} -> rendered
case MDEx.to_html(content || "",
extension: [table: true, strikethrough: true, autolink: true],
render: [escape: true]
) do
{:ok, rendered} -> rendered
{:error, _reason} -> ""
end
raw(html)

View File

@@ -7,9 +7,11 @@ defmodule BDS.Desktop.ShellLive.SidebarEvents do
term() ->
Phoenix.LiveView.Socket.t())) ::
{:noreply, Phoenix.LiveView.Socket.t()}
def handle(socket, event, params, reload)
def handle(socket, event, params, reload) do
{:noreply, dispatch(event, socket, params, reload)}
end
def handle(socket, "toggle_sidebar_filters", _params, reload) do
defp dispatch("toggle_sidebar_filters", socket, _params, reload) do
socket =
ShellSidebarState.put_filter_panel_state(socket, fn state ->
if state.visible do
@@ -25,167 +27,153 @@ defmodule BDS.Desktop.ShellLive.SidebarEvents do
end
end)
{:noreply, reload.(socket, socket.assigns.workbench)}
reload.(socket, socket.assigns.workbench)
end
def handle(socket, "toggle_sidebar_archive", _params, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{state | archive_collapsed: not state.archive_collapsed}
end)
|> reload.(socket.assigns.workbench)}
defp dispatch("toggle_sidebar_archive", socket, _params, reload) do
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{state | archive_collapsed: not state.archive_collapsed}
end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "toggle_sidebar_tags", _params, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{state | tags_collapsed: not state.tags_collapsed}
end)
|> reload.(socket.assigns.workbench)}
defp dispatch("toggle_sidebar_tags", socket, _params, reload) do
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{state | tags_collapsed: not state.tags_collapsed}
end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "toggle_sidebar_categories", _params, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{state | categories_collapsed: not state.categories_collapsed}
end)
|> reload.(socket.assigns.workbench)}
defp dispatch("toggle_sidebar_categories", socket, _params, reload) do
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{state | categories_collapsed: not state.categories_collapsed}
end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "update_sidebar_search", %{"sidebar_filters" => params}, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filters(fn filters ->
Map.put(
filters,
:search,
ShellSidebarState.normalize_filter_string(Map.get(params, "search"))
)
end)
|> reload.(socket.assigns.workbench)}
defp dispatch("update_sidebar_search", socket, %{"sidebar_filters" => params}, reload) do
socket
|> ShellSidebarState.put_filters(fn filters ->
Map.put(
filters,
:search,
ShellSidebarState.normalize_filter_string(Map.get(params, "search"))
)
end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "clear_sidebar_search", _params, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :search, nil) end)
|> reload.(socket.assigns.workbench)}
defp dispatch("clear_sidebar_search", socket, _params, reload) do
socket
|> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :search, nil) end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "clear_sidebar_tags", _params, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :tags, []) end)
|> reload.(socket.assigns.workbench)}
defp dispatch("clear_sidebar_tags", socket, _params, reload) do
socket
|> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :tags, []) end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "clear_sidebar_categories", _params, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :categories, []) end)
|> reload.(socket.assigns.workbench)}
defp dispatch("clear_sidebar_categories", socket, _params, reload) do
socket
|> ShellSidebarState.put_filters(fn filters -> Map.put(filters, :categories, []) end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "toggle_sidebar_tag", %{"tag" => tag}, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filters(fn filters ->
ShellSidebarState.toggle_filter_value(filters, :tags, tag)
end)
|> reload.(socket.assigns.workbench)}
defp dispatch("toggle_sidebar_tag", socket, %{"tag" => tag}, reload) do
socket
|> ShellSidebarState.put_filters(fn filters ->
ShellSidebarState.toggle_filter_value(filters, :tags, tag)
end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "toggle_sidebar_category", %{"category" => category}, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filters(fn filters ->
ShellSidebarState.toggle_filter_value(filters, :categories, category)
end)
|> reload.(socket.assigns.workbench)}
defp dispatch("toggle_sidebar_category", socket, %{"category" => category}, reload) do
socket
|> ShellSidebarState.put_filters(fn filters ->
ShellSidebarState.toggle_filter_value(filters, :categories, category)
end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "select_sidebar_year", %{"year" => year}, reload) do
defp dispatch("select_sidebar_year", socket, %{"year" => year}, reload) do
parsed_year = ShellSidebarState.parse_optional_integer(year)
{:noreply,
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{
state
| archive_collapsed: false,
expanded_year: if(state.expanded_year == parsed_year, do: nil, else: parsed_year)
}
end)
|> ShellSidebarState.put_filters(fn filters ->
filters
|> Map.put(:year, parsed_year)
|> Map.put(:month, nil)
end)
|> reload.(socket.assigns.workbench)}
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{
state
| archive_collapsed: false,
expanded_year: if(state.expanded_year == parsed_year, do: nil, else: parsed_year)
}
end)
|> ShellSidebarState.put_filters(fn filters ->
filters
|> Map.put(:year, parsed_year)
|> Map.put(:month, nil)
end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "select_sidebar_month", %{"year" => year, "month" => month}, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{
state
| archive_collapsed: false,
expanded_year: ShellSidebarState.parse_optional_integer(year)
}
end)
|> ShellSidebarState.put_filters(fn filters ->
filters
|> Map.put(:year, ShellSidebarState.parse_optional_integer(year))
|> Map.put(:month, ShellSidebarState.parse_optional_integer(month))
end)
|> reload.(socket.assigns.workbench)}
defp dispatch("select_sidebar_month", socket, %{"year" => year, "month" => month}, reload) do
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{
state
| archive_collapsed: false,
expanded_year: ShellSidebarState.parse_optional_integer(year)
}
end)
|> ShellSidebarState.put_filters(fn filters ->
filters
|> Map.put(:year, ShellSidebarState.parse_optional_integer(year))
|> Map.put(:month, ShellSidebarState.parse_optional_integer(month))
end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "clear_sidebar_month", _params, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{state | archive_collapsed: false}
end)
|> ShellSidebarState.put_filters(fn filters ->
filters |> Map.put(:year, nil) |> Map.put(:month, nil)
end)
|> reload.(socket.assigns.workbench)}
defp dispatch("clear_sidebar_month", socket, _params, reload) do
socket
|> ShellSidebarState.put_filter_panel_state(fn state ->
%{state | archive_collapsed: false}
end)
|> ShellSidebarState.put_filters(fn filters ->
filters |> Map.put(:year, nil) |> Map.put(:month, nil)
end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "clear_sidebar_filters", _params, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filters(fn filters ->
filters
|> Map.put(:search, nil)
|> Map.put(:year, nil)
|> Map.put(:month, nil)
|> Map.put(:tags, [])
|> Map.put(:categories, [])
|> Map.put(
:display_limit,
ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data)
)
end)
|> reload.(socket.assigns.workbench)}
defp dispatch("clear_sidebar_filters", socket, _params, reload) do
socket
|> ShellSidebarState.put_filters(fn filters ->
filters
|> Map.put(:search, nil)
|> Map.put(:year, nil)
|> Map.put(:month, nil)
|> Map.put(:tags, [])
|> Map.put(:categories, [])
|> Map.put(
:display_limit,
ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data)
)
end)
|> reload.(socket.assigns.workbench)
end
def handle(socket, "load_more_sidebar", _params, reload) do
{:noreply,
socket
|> ShellSidebarState.put_filters(fn filters ->
Map.update(
filters,
:display_limit,
ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data),
&(&1 + ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data))
)
end)
|> reload.(socket.assigns.workbench)}
defp dispatch("load_more_sidebar", socket, _params, reload) do
socket
|> ShellSidebarState.put_filters(fn filters ->
Map.update(
filters,
:display_limit,
ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data),
&(&1 + ShellSidebarState.sidebar_page_size(socket.assigns.sidebar_data))
)
end)
|> reload.(socket.assigns.workbench)
end
end

View File

@@ -223,54 +223,11 @@ defmodule BDS.Generation.Data do
end
defp published_post_snapshot(project_data_dir, %Post{} = post) do
cond do
is_binary(post.file_path) and post.file_path != "" ->
project_data_dir
|> Path.join(post.file_path)
|> read_post_snapshot(post)
post.status == :published ->
post
true ->
nil
end
published_snapshot(project_data_dir, post.file_path, post, &read_post_snapshot/2)
end
defp read_post_snapshot(full_path, %Post{} = fallback_post) do
case File.read(full_path) do
{:ok, contents} ->
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
%Post{
fallback_post
| id: DocumentFields.get(fields, "id", fallback_post.id),
title: DocumentFields.get(fields, "title", fallback_post.title) || "",
slug: DocumentFields.fetch!(fields, "slug"),
excerpt: Map.get(fields, "excerpt"),
content: nil,
status: :published,
author: Map.get(fields, "author"),
language: Map.get(fields, "language", fallback_post.language),
do_not_translate:
DocumentFields.get(
fields,
"doNotTranslate",
fallback_post.do_not_translate || false
),
template_slug:
DocumentFields.get(fields, "templateSlug", fallback_post.template_slug),
created_at: DocumentFields.get(fields, "createdAt", fallback_post.created_at),
updated_at: DocumentFields.get(fields, "updatedAt", fallback_post.updated_at),
published_at: DocumentFields.get(fields, "publishedAt", fallback_post.published_at),
file_path: fallback_post.file_path,
tags: Map.get(fields, "tags", fallback_post.tags || []),
categories: Map.get(fields, "categories", fallback_post.categories || [])
}
{:error, _reason} ->
if fallback_post.status == :published, do: fallback_post, else: nil
end
read_snapshot(full_path, fallback_post, &build_post_snapshot/2)
end
defp build_generation_route_posts(
@@ -324,46 +281,81 @@ defmodule BDS.Generation.Data do
end
defp published_translation_snapshot(project_data_dir, %Translation{} = translation) do
cond do
is_binary(translation.file_path) and translation.file_path != "" ->
project_data_dir
|> Path.join(translation.file_path)
|> read_translation_snapshot(translation)
translation.status == :published ->
translation
true ->
nil
end
published_snapshot(
project_data_dir,
translation.file_path,
translation,
&read_translation_snapshot/2
)
end
defp read_translation_snapshot(full_path, %Translation{} = fallback_translation) do
read_snapshot(full_path, fallback_translation, &build_translation_snapshot/2)
end
defp read_snapshot(full_path, fallback_record, builder) do
case File.read(full_path) do
{:ok, contents} ->
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
%Translation{
fallback_translation
| id: DocumentFields.get(fields, "id", fallback_translation.id),
translation_for: DocumentFields.fetch!(fields, "translationFor"),
language: DocumentFields.fetch!(fields, "language"),
title: DocumentFields.get(fields, "title", fallback_translation.title) || "",
excerpt: Map.get(fields, "excerpt", fallback_translation.excerpt),
content: nil,
status: :published,
created_at: DocumentFields.get(fields, "createdAt", fallback_translation.created_at),
updated_at: DocumentFields.get(fields, "updatedAt", fallback_translation.updated_at),
published_at:
DocumentFields.get(fields, "publishedAt", fallback_translation.published_at),
file_path: fallback_translation.file_path
}
builder.(fields, fallback_record)
{:error, _reason} ->
if fallback_translation.status == :published, do: fallback_translation, else: nil
if fallback_record.status == :published, do: fallback_record, else: nil
end
end
defp published_snapshot(project_data_dir, file_path, fallback_record, reader)
when is_binary(file_path) and file_path != "" do
project_data_dir
|> Path.join(file_path)
|> reader.(fallback_record)
end
defp published_snapshot(_project_data_dir, _file_path, %{status: :published} = fallback_record, _reader),
do: fallback_record
defp published_snapshot(_project_data_dir, _file_path, _fallback_record, _reader), do: nil
defp build_post_snapshot(fields, %Post{} = fallback_post) do
%Post{
fallback_post
| id: DocumentFields.get(fields, "id", fallback_post.id),
title: DocumentFields.get(fields, "title", fallback_post.title) || "",
slug: DocumentFields.fetch!(fields, "slug"),
excerpt: Map.get(fields, "excerpt"),
content: nil,
status: :published,
author: Map.get(fields, "author"),
language: Map.get(fields, "language", fallback_post.language),
do_not_translate:
DocumentFields.get(fields, "doNotTranslate", fallback_post.do_not_translate || false),
template_slug: DocumentFields.get(fields, "templateSlug", fallback_post.template_slug),
created_at: DocumentFields.get(fields, "createdAt", fallback_post.created_at),
updated_at: DocumentFields.get(fields, "updatedAt", fallback_post.updated_at),
published_at: DocumentFields.get(fields, "publishedAt", fallback_post.published_at),
file_path: fallback_post.file_path,
tags: Map.get(fields, "tags", fallback_post.tags || []),
categories: Map.get(fields, "categories", fallback_post.categories || [])
}
end
defp build_translation_snapshot(fields, %Translation{} = fallback_translation) do
%Translation{
fallback_translation
| id: DocumentFields.get(fields, "id", fallback_translation.id),
translation_for: DocumentFields.fetch!(fields, "translationFor"),
language: DocumentFields.fetch!(fields, "language"),
title: DocumentFields.get(fields, "title", fallback_translation.title) || "",
excerpt: Map.get(fields, "excerpt", fallback_translation.excerpt),
content: nil,
status: :published,
created_at: DocumentFields.get(fields, "createdAt", fallback_translation.created_at),
updated_at: DocumentFields.get(fields, "updatedAt", fallback_translation.updated_at),
published_at: DocumentFields.get(fields, "publishedAt", fallback_translation.published_at),
file_path: fallback_translation.file_path
}
end
defp build_published_translation_variant(post, translation) do
%{
id: translation.id,

View File

@@ -51,114 +51,102 @@ defmodule BDS.Generation.Outputs do
@spec core_route_paths(map(), [map()], String.t() | nil) :: [String.t()]
def core_route_paths(plan, published_list_posts, route_language) do
if :core in plan.sections do
section_route_paths(plan, :core, fn ->
root_route_paths(route_language, length(published_list_posts), plan.max_posts_per_page)
else
[]
end
end)
end
@spec page_route_paths(map(), [map()], String.t() | nil) :: [String.t()]
def page_route_paths(plan, route_posts, route_language) do
if :core in plan.sections do
section_route_paths(plan, :core, fn ->
route_posts
|> Enum.filter(&("page" in (&1.categories || [])))
|> Enum.map(&page_output_path(&1.slug, route_language))
else
[]
end
end)
end
@spec single_route_paths(map(), [map()], String.t() | nil) :: [String.t()]
def single_route_paths(plan, route_posts, route_language) do
if :single in plan.sections do
section_route_paths(plan, :single, fn ->
Enum.map(route_posts, &route_post_output_path(&1, route_language))
else
[]
end
end)
end
@spec category_route_paths(map(), map(), String.t() | nil) :: [String.t()]
def category_route_paths(plan, posts_by_category, route_language) do
if :category in plan.sections do
Enum.flat_map(posts_by_category, fn {category, posts} ->
post_count = length(posts)
paginated_archive_paths(
route_language,
["category", archive_route_segment(category)],
post_count,
plan.max_posts_per_page
)
end)
else
[]
end
section_route_paths(plan, :category, fn ->
archive_collection_route_paths(
posts_by_category,
route_language,
plan.max_posts_per_page,
fn category -> ["category", archive_route_segment(category)] end
)
end)
end
@spec tag_route_paths(map(), map(), String.t() | nil) :: [String.t()]
def tag_route_paths(plan, posts_by_tag, route_language) do
if :tag in plan.sections do
Enum.flat_map(posts_by_tag, fn {tag, posts} ->
post_count = length(posts)
paginated_archive_paths(
route_language,
["tag", archive_route_segment(tag)],
post_count,
plan.max_posts_per_page
)
end)
else
[]
end
section_route_paths(plan, :tag, fn ->
archive_collection_route_paths(
posts_by_tag,
route_language,
plan.max_posts_per_page,
fn tag -> ["tag", archive_route_segment(tag)] end
)
end)
end
@spec date_route_paths(map(), map(), String.t() | nil) :: [String.t()]
def date_route_paths(plan, post_index, route_language) do
if :date in plan.sections do
section_route_paths(plan, :date, fn ->
year_paths =
Enum.flat_map(post_index.posts_by_year, fn {year, posts} ->
post_count = length(posts)
paginated_archive_paths(
route_language,
[Integer.to_string(year)],
post_count,
plan.max_posts_per_page
)
end)
archive_collection_route_paths(
post_index.posts_by_year,
route_language,
plan.max_posts_per_page,
fn year -> [Integer.to_string(year)] end
)
month_paths =
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
[year, month] = String.split(year_month, "/", parts: 2)
post_count = length(posts)
paginated_archive_paths(
route_language,
[year, month],
post_count,
plan.max_posts_per_page
)
end)
archive_collection_route_paths(
post_index.posts_by_year_month,
route_language,
plan.max_posts_per_page,
fn year_month -> String.split(year_month, "/", parts: 2) end
)
day_paths =
Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} ->
[year, month, day] = String.split(year_month_day, "/", parts: 3)
post_count = length(posts)
paginated_archive_paths(
route_language,
[year, month, day],
post_count,
plan.max_posts_per_page
)
end)
archive_collection_route_paths(
post_index.posts_by_year_month_day,
route_language,
plan.max_posts_per_page,
fn year_month_day -> String.split(year_month_day, "/", parts: 3) end
)
year_paths ++ month_paths ++ day_paths
else
[]
end
end)
end
defp section_route_paths(plan, section, builder) do
if section in plan.sections, do: builder.(), else: []
end
defp archive_collection_route_paths(
collections,
route_language,
max_posts_per_page,
segment_builder
) do
Enum.flat_map(collections, fn {key, posts} ->
post_count = length(posts)
paginated_archive_paths(
route_language,
segment_builder.(key),
post_count,
max_posts_per_page
)
end)
end
@spec build_archive_outputs(map(), map(), map()) :: [{String.t(), iodata()}]

View File

@@ -17,13 +17,9 @@ defmodule BDS.Generation.Paths do
month = month |> Integer.to_string() |> String.pad_leading(2, "0")
day = day |> Integer.to_string() |> String.pad_leading(2, "0")
path_parts = [year, month, day, post.slug, "index.html"]
case language do
nil -> Path.join(path_parts)
"" -> Path.join(path_parts)
value -> Path.join([value | path_parts])
end
language
|> maybe_prepend_language([year, month, day, post.slug, "index.html"])
|> Path.join()
end
@spec paginated_archive_paths(language(), [String.t()], non_neg_integer(), pos_integer()) ::
@@ -46,22 +42,15 @@ defmodule BDS.Generation.Paths do
end
@spec root_output_path(language(), pos_integer()) :: String.t()
def root_output_path(nil, 1), do: "index.html"
def root_output_path("", 1), do: "index.html"
def root_output_path(route_language, 1), do: Path.join(route_language, "index.html")
def root_output_path(nil, page_number),
do: Path.join(["page", Integer.to_string(page_number), "index.html"])
def root_output_path("", page_number), do: root_output_path(nil, page_number)
def root_output_path(route_language, page_number),
do: Path.join([route_language, "page", Integer.to_string(page_number), "index.html"])
do:
route_language
|> maybe_prepend_language(root_output_path_segments(page_number))
|> Path.join()
@spec page_output_path(String.t(), language()) :: String.t()
def page_output_path(slug, nil), do: Path.join([slug, "index.html"])
def page_output_path(slug, ""), do: page_output_path(slug, nil)
def page_output_path(slug, language), do: Path.join([language, slug, "index.html"])
def page_output_path(slug, language),
do: language |> maybe_prepend_language([slug, "index.html"]) |> Path.join()
@spec pagination_for_page(
pos_integer(),
@@ -102,21 +91,11 @@ defmodule BDS.Generation.Paths do
do: archive_href(route_language, segments, page_number)
@spec root_page_href(language(), integer()) :: String.t()
def root_page_href(route_language, page_number) when page_number <= 1 do
case route_language do
nil -> "/"
"" -> "/"
language -> "/#{language}/"
end
end
def root_page_href(route_language, page_number) when page_number <= 1,
do: language_route_prefix(route_language) <> "/"
def root_page_href(route_language, page_number) do
base =
case route_language do
nil -> ""
"" -> ""
language -> "/#{language}"
end
base = language_route_prefix(route_language)
"#{base}/page/#{page_number}/"
end
@@ -160,13 +139,8 @@ defmodule BDS.Generation.Paths do
end
@spec archive_path(language(), [String.t()]) :: String.t()
def archive_path(nil, segments), do: Path.join(segments ++ ["index.html"])
def archive_path("", segments), do: Path.join(segments ++ ["index.html"])
def archive_path(language, segments) do
prefix = if language in [nil, ""], do: [], else: [language]
Path.join(prefix ++ segments ++ ["index.html"])
end
def archive_path(language, segments),
do: language |> maybe_prepend_language(segments ++ ["index.html"]) |> Path.join()
@spec archive_route_segment(any()) :: String.t()
def archive_route_segment(nil), do: ""
@@ -271,6 +245,15 @@ defmodule BDS.Generation.Paths do
@spec truthy_flag?(term()) :: boolean()
def truthy_flag?(value), do: value not in [false, nil]
defp root_output_path_segments(1), do: ["index.html"]
defp root_output_path_segments(page_number), do: ["page", Integer.to_string(page_number), "index.html"]
defp maybe_prepend_language(language, segments) when language in [nil, ""], do: segments
defp maybe_prepend_language(language, segments), do: [language | segments]
defp language_route_prefix(language) when language in [nil, ""], do: ""
defp language_route_prefix(language), do: "/#{language}"
@doc "Returns the local-time `{year, month, day}` for a unix-ms-or-binary timestamp."
@spec local_date_parts!(term()) :: {integer(), integer(), integer()}
def local_date_parts!(value) do

View File

@@ -47,25 +47,7 @@ defmodule BDS.Generation.Renderers do
@spec render_archive_page(map(), String.t(), [map()], String.t() | nil, String.t(), map()) ::
String.t()
def render_archive_page(plan, title, posts, language, kind, pagination) do
fallback = fn ->
items =
posts
|> Enum.map(fn post -> ["<li>", post.title, "</li>"] end)
|> IO.iodata_to_binary()
[
"<html><body data-kind=\"",
kind,
"\" data-language=\"",
to_string(language),
"\"><h1>",
title,
"</h1><ul>",
items,
"</ul></body></html>"
]
|> IO.iodata_to_binary()
end
fallback = fn -> render_inline_list_page(title, posts, language, kind) end
render_list_output(
plan,
@@ -92,23 +74,7 @@ defmodule BDS.Generation.Renderers do
@spec render_date_archive_page(map(), String.t(), map(), [map()], String.t() | nil, map()) ::
String.t()
def render_date_archive_page(plan, label, archive_context, posts, language, pagination) do
fallback = fn ->
items =
posts
|> Enum.map(fn post -> ["<li>", post.title, "</li>"] end)
|> IO.iodata_to_binary()
[
"<html><body data-kind=\"date\" data-language=\"",
to_string(language),
"\"><h1>",
label,
"</h1><ul>",
items,
"</ul></body></html>"
]
|> IO.iodata_to_binary()
end
fallback = fn -> render_inline_list_page(label, posts, language, "date") end
render_list_output(
plan,
@@ -124,10 +90,7 @@ defmodule BDS.Generation.Renderers do
@doc "Try the project's post template; on error, fall back to the inline `fallback` thunk."
@spec render_post_output(String.t(), String.t() | nil, map(), (-> String.t())) :: String.t()
def render_post_output(project_id, template_slug, assigns, fallback) do
case Rendering.render_post_page(project_id, template_slug, assigns) do
{:ok, rendered} -> rendered
{:error, _reason} -> fallback.()
end
render_with_fallback(fn -> Rendering.render_post_page(project_id, template_slug, assigns) end, fallback)
end
@doc "Render a list/archive page through the project template, falling back to inline."
@@ -151,30 +114,34 @@ defmodule BDS.Generation.Renderers do
fallback
)
when is_binary(project_id) do
case Rendering.render_list_page(project_id, %{
language: language,
language_prefix: Paths.language_prefix(language, main_language),
page_title: page_title,
posts: posts,
archive_context: archive_context,
pagination: pagination
}) do
{:ok, rendered} -> rendered
{:error, _reason} -> fallback.()
end
render_with_fallback(
fn ->
Rendering.render_list_page(project_id, %{
language: language,
language_prefix: Paths.language_prefix(language, main_language),
page_title: page_title,
posts: posts,
archive_context: archive_context,
pagination: pagination
})
end,
fallback
)
end
@doc "Render the project's 404 page via its template, falling back to a static page."
@spec render_not_found_output(map(), String.t() | nil) :: String.t()
def render_not_found_output(%{project_id: project_id, language: main_language}, language)
when is_binary(project_id) do
case Rendering.render_not_found_page(project_id, %{
language: language,
language_prefix: Paths.language_prefix(language, main_language)
}) do
{:ok, rendered} -> rendered
{:error, _reason} -> render_not_found_page(language)
end
render_with_fallback(
fn ->
Rendering.render_not_found_page(project_id, %{
language: language,
language_prefix: Paths.language_prefix(language, main_language)
})
end,
fn -> render_not_found_page(language) end
)
end
@doc "Static fallback HTML for a 404 page."
@@ -227,6 +194,33 @@ defmodule BDS.Generation.Renderers do
end
end
defp render_with_fallback(render_fun, fallback) do
case render_fun.() do
{:ok, rendered} -> rendered
{:error, _reason} -> fallback.()
end
end
defp render_inline_list_page(title, posts, language, kind) do
items =
posts
|> Enum.map(fn post -> ["<li>", post.title, "</li>"] end)
|> IO.iodata_to_binary()
[
"<html><body data-kind=\"",
kind,
"\" data-language=\"",
to_string(language),
"\"><h1>",
title,
"</h1><ul>",
items,
"</ul></body></html>"
]
|> IO.iodata_to_binary()
end
defp parse_frontmatter_body(contents) do
case String.split(contents, "\n---\n", parts: 2) do
[_frontmatter, body] -> String.trim_trailing(body, "\n")

View File

@@ -26,12 +26,14 @@ defmodule BDS.Generation.Sitemap do
urls =
[
url_entry(
Paths.url_for_path(plan.base_url, "/"),
build_url_entry(
plan.base_url,
"/",
latest_post_updated_at,
"daily",
"1.0",
build_hreflang_links(plan.base_url, "/", plan.language, all_languages)
plan.language,
all_languages
)
] ++
Enum.map(
@@ -39,35 +41,41 @@ defmodule BDS.Generation.Sitemap do
fn page_number ->
page_path = "/page/#{page_number}"
url_entry(
Paths.url_for_path(plan.base_url, page_path),
build_url_entry(
plan.base_url,
page_path,
latest_post_updated_at,
"daily",
"0.9",
build_hreflang_links(plan.base_url, page_path, plan.language, all_languages)
plan.language,
all_languages
)
end
) ++
Enum.map(translatable_posts, fn post ->
post_path = Paths.relative_path_to_url_path(Paths.post_output_path(post))
url_entry(
Paths.url_for_path(plan.base_url, post_path),
build_url_entry(
plan.base_url,
post_path,
unix_ms_to_iso8601(post.updated_at),
"monthly",
"0.8",
build_hreflang_links(plan.base_url, post_path, plan.language, all_languages)
plan.language,
all_languages
)
end) ++
Enum.map(do_not_translate_posts, fn post ->
post_path = Paths.relative_path_to_url_path(Paths.post_output_path(post))
url_entry(
Paths.url_for_path(plan.base_url, post_path),
build_url_entry(
plan.base_url,
post_path,
unix_ms_to_iso8601(post.updated_at),
"monthly",
"0.8",
build_hreflang_links(plan.base_url, post_path, plan.language, [plan.language])
plan.language,
[plan.language]
)
end) ++
Enum.flat_map(translatable_posts ++ do_not_translate_posts, fn post ->
@@ -80,12 +88,14 @@ defmodule BDS.Generation.Sitemap do
else: all_languages
[
url_entry(
Paths.url_for_path(plan.base_url, page_path),
build_url_entry(
plan.base_url,
page_path,
unix_ms_to_iso8601(post.updated_at),
"weekly",
"0.7",
build_hreflang_links(plan.base_url, page_path, plan.language, languages)
plan.language,
languages
)
]
else
@@ -95,12 +105,14 @@ defmodule BDS.Generation.Sitemap do
Enum.map(Enum.sort_by(post_index.posts_by_year, &elem(&1, 0), :desc), fn {year, _posts} ->
year_path = "/#{year}"
url_entry(
Paths.url_for_path(plan.base_url, year_path),
build_url_entry(
plan.base_url,
year_path,
latest_post_updated_at,
"monthly",
"0.5",
build_hreflang_links(plan.base_url, year_path, plan.language, all_languages)
plan.language,
all_languages
)
end) ++
Enum.map(
@@ -108,12 +120,14 @@ defmodule BDS.Generation.Sitemap do
fn {year_month, _posts} ->
month_path = "/#{year_month}"
url_entry(
Paths.url_for_path(plan.base_url, month_path),
build_url_entry(
plan.base_url,
month_path,
latest_post_updated_at,
"monthly",
"0.5",
build_hreflang_links(plan.base_url, month_path, plan.language, all_languages)
plan.language,
all_languages
)
end
) ++
@@ -122,35 +136,41 @@ defmodule BDS.Generation.Sitemap do
fn {year_month_day, _posts} ->
day_path = "/#{year_month_day}"
url_entry(
Paths.url_for_path(plan.base_url, day_path),
build_url_entry(
plan.base_url,
day_path,
latest_post_updated_at,
"monthly",
"0.4",
build_hreflang_links(plan.base_url, day_path, plan.language, all_languages)
plan.language,
all_languages
)
end
) ++
Enum.map(Enum.sort_by(post_index.posts_by_category, &elem(&1, 0)), fn {category, _posts} ->
category_path = "/category/#{Paths.archive_route_segment(category)}"
url_entry(
Paths.url_for_path(plan.base_url, category_path),
build_url_entry(
plan.base_url,
category_path,
latest_post_updated_at,
"weekly",
"0.6",
build_hreflang_links(plan.base_url, category_path, plan.language, all_languages)
plan.language,
all_languages
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_tag, &elem(&1, 0)), fn {tag, _posts} ->
tag_path = "/tag/#{Paths.archive_route_segment(tag)}"
url_entry(
Paths.url_for_path(plan.base_url, tag_path),
build_url_entry(
plan.base_url,
tag_path,
latest_post_updated_at,
"weekly",
"0.6",
build_hreflang_links(plan.base_url, tag_path, plan.language, all_languages)
plan.language,
all_languages
)
end)
@@ -274,6 +294,16 @@ defmodule BDS.Generation.Sitemap do
]
end
defp build_url_entry(base_url, url_path, lastmod, changefreq, priority, main_language, languages) do
url_entry(
Paths.url_for_path(base_url, url_path),
lastmod,
changefreq,
priority,
build_hreflang_links(base_url, url_path, main_language, languages)
)
end
defp url_entry(loc, lastmod, changefreq, priority, hreflang_links) do
[
" <url>",

View File

@@ -31,19 +31,13 @@ defmodule BDS.Generation.Validation do
published_route_posts,
generated_file_updated_at
) do
Enum.map(published_route_posts, fn post ->
relative_path = BDS.Generation.Paths.post_output_path(post)
%{
post_url_path: relative_path_to_url_path(relative_path),
post_file_path:
source_full_path(
project_data_dir,
Map.get(post, :translation_file_path) || post.file_path
),
generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0)
}
end)
build_timestamp_checks(
project_data_dir,
published_route_posts,
generated_file_updated_at,
&BDS.Generation.Paths.post_output_path/1,
fn post -> Map.get(post, :translation_file_path) || post.file_path end
)
end
@spec build_language_post_timestamp_checks(String.t(), String.t(), [map()], map()) :: [map()]
@@ -53,12 +47,28 @@ defmodule BDS.Generation.Validation do
published_posts,
generated_file_updated_at
) do
Enum.map(published_posts, fn post ->
relative_path = BDS.Generation.Paths.post_output_path(post, language)
build_timestamp_checks(
project_data_dir,
published_posts,
generated_file_updated_at,
fn post -> BDS.Generation.Paths.post_output_path(post, language) end,
& &1.file_path
)
end
defp build_timestamp_checks(
project_data_dir,
posts,
generated_file_updated_at,
relative_path_fun,
source_file_fun
) do
Enum.map(posts, fn post ->
relative_path = relative_path_fun.(post)
%{
post_url_path: relative_path_to_url_path(relative_path),
post_file_path: source_full_path(project_data_dir, post.file_path),
post_file_path: source_full_path(project_data_dir, source_file_fun.(post)),
generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0)
}
end)

View File

@@ -175,11 +175,11 @@ defmodule BDS.ImportAnalysis do
resolution: if(status == "conflict", do: "ignore", else: nil),
existing_id: existing && existing.id,
existing_title: existing && existing.title,
author: blank_to_nil(wxr_post.creator),
excerpt: blank_to_nil(wxr_post.excerpt),
author: BDS.MapUtils.blank_to_nil(wxr_post.creator),
excerpt: BDS.MapUtils.blank_to_nil(wxr_post.excerpt),
categories: wxr_post.categories,
tags: wxr_post.tags,
wp_status: blank_to_nil(wxr_post.status),
wp_status: BDS.MapUtils.blank_to_nil(wxr_post.status),
content_markdown: content_markdown,
content_checksum: content_checksum,
content_preview: String.slice(content_markdown, 0, 200),
@@ -236,7 +236,7 @@ defmodule BDS.ImportAnalysis do
existing_id: existing && existing.id,
existing_title: existing && existing.title,
mime_type: wxr_media.mime_type,
description: blank_to_nil(wxr_media.description),
description: BDS.MapUtils.blank_to_nil(wxr_media.description),
parent_wp_id: wxr_media.parent_id,
source_file: source_file,
checksum: checksum,
@@ -264,7 +264,7 @@ defmodule BDS.ImportAnalysis do
status: item.status
}
maybe_put(base, :resolution, item.resolution)
BDS.MapUtils.maybe_put(base, :resolution, item.resolution)
end
defp summary_item(item) do
@@ -276,7 +276,7 @@ defmodule BDS.ImportAnalysis do
status: item.status
}
maybe_put(base, :resolution, item.resolution)
BDS.MapUtils.maybe_put(base, :resolution, item.resolution)
end
defp summarize_post_items(items) do
@@ -546,10 +546,4 @@ defmodule BDS.ImportAnalysis do
end)
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp blank_to_nil(nil), do: nil
defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value
end

View File

@@ -16,11 +16,12 @@ defmodule BDS.ImportDefinitions do
%ImportDefinition{}
|> ImportDefinition.changeset(%{
id: Ecto.UUID.generate(),
project_id: attr(attrs, :project_id),
name: attr(attrs, :name) || "",
wxr_file_path: attr(attrs, :wxr_file_path),
uploads_folder_path: attr(attrs, :uploads_folder_path),
last_analysis_result: normalize_analysis_result(attr(attrs, :last_analysis_result)),
project_id: BDS.MapUtils.attr(attrs, :project_id),
name: BDS.MapUtils.attr(attrs, :name) || "",
wxr_file_path: BDS.MapUtils.attr(attrs, :wxr_file_path),
uploads_folder_path: BDS.MapUtils.attr(attrs, :uploads_folder_path),
last_analysis_result:
normalize_analysis_result(BDS.MapUtils.attr(attrs, :last_analysis_result)),
created_at: now,
updated_at: now
})
@@ -39,12 +40,15 @@ defmodule BDS.ImportDefinitions do
%ImportDefinition{} = definition ->
updates =
%{}
|> maybe_put(:name, attr(attrs, :name))
|> maybe_put(:wxr_file_path, attr(attrs, :wxr_file_path))
|> maybe_put(:uploads_folder_path, attr(attrs, :uploads_folder_path))
|> maybe_put(
|> BDS.MapUtils.maybe_put(:name, BDS.MapUtils.attr(attrs, :name))
|> BDS.MapUtils.maybe_put(:wxr_file_path, BDS.MapUtils.attr(attrs, :wxr_file_path))
|> BDS.MapUtils.maybe_put(
:uploads_folder_path,
BDS.MapUtils.attr(attrs, :uploads_folder_path)
)
|> BDS.MapUtils.maybe_put(
:last_analysis_result,
normalize_analysis_result(attr(attrs, :last_analysis_result))
normalize_analysis_result(BDS.MapUtils.attr(attrs, :last_analysis_result))
)
|> Map.put(:updated_at, Persistence.now_ms())
@@ -85,11 +89,6 @@ defmodule BDS.ImportDefinitions do
)
end
defp attr(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key))
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
alias BDS.MapUtils
defp normalize_analysis_result(nil), do: nil

View File

@@ -145,13 +145,8 @@ defmodule BDS.MCP.Server do
defp dispatch_http_request({:ok, %{method: "POST", target: target} = request}) do
case URI.parse(target) do
%URI{path: "/mcp"} ->
case GenServer.call(__MODULE__, {:http_request, request}, 5_000) do
{:ok, status, body} ->
http_response(status, Jason.encode!(body), "application/json", request.headers)
{:error, status, body} ->
http_response(status, body, "text/plain", request.headers)
end
GenServer.call(__MODULE__, {:http_request, request}, 5_000)
|> encode_http_response(request.headers)
_other ->
http_error_response(404, request.headers)
@@ -287,17 +282,21 @@ defmodule BDS.MCP.Server do
]
end
defp http_response(status, body, content_type, headers) do
reason =
case status do
200 -> "OK"
204 -> "No Content"
400 -> "Bad Request"
403 -> "Forbidden"
404 -> "Not Found"
_other -> "Internal Server Error"
end
defp encode_http_response({:ok, status, body}, headers),
do: json_http_response(status, body, headers)
defp encode_http_response({:error, status, body}, headers),
do: text_http_response(status, body, headers)
defp json_http_response(status, body, headers) do
http_response(status, Jason.encode!(body), "application/json", headers)
end
defp text_http_response(status, body, headers) do
http_response(status, body, "text/plain", headers)
end
defp http_response(status, body, content_type, headers) do
header_lines =
[
{"content-type", content_type <> "; charset=utf-8"},
@@ -311,7 +310,7 @@ defmodule BDS.MCP.Server do
"HTTP/1.1 ",
Integer.to_string(status),
" ",
reason,
http_reason_phrase(status),
"\r\n",
header_lines,
"\r\n",
@@ -320,6 +319,13 @@ defmodule BDS.MCP.Server do
|> IO.iodata_to_binary()
end
defp http_reason_phrase(200), do: "OK"
defp http_reason_phrase(204), do: "No Content"
defp http_reason_phrase(400), do: "Bad Request"
defp http_reason_phrase(403), do: "Forbidden"
defp http_reason_phrase(404), do: "Not Found"
defp http_reason_phrase(_status), do: "Internal Server Error"
defp http_error_response(status, headers \\ %{}),
do: http_response(status, reason_body(status), "text/plain", headers)

View File

@@ -16,6 +16,22 @@ defmodule BDS.MCP.Tools do
alias BDS.Templates
@proposal_ttl_app_ms 30 * 60 * 1000
@tools [
{"check_term", true},
{"search_posts", true},
{"count_posts", true},
{"read_post_by_slug", true},
{"get_post_translations", true},
{"get_media_translations", true},
{"upsert_media_translation", false},
{"draft_post", false},
{"propose_script", false},
{"propose_template", false},
{"propose_media_metadata", false},
{"propose_post_metadata", false},
{"accept_proposal", false},
{"discard_proposal", false}
]
@typedoc "Tool descriptor returned by `list/0`."
@type descriptor :: %{
@@ -28,22 +44,7 @@ defmodule BDS.MCP.Tools do
@spec list() :: [descriptor()]
def list do
[
tool("check_term", true),
tool("search_posts", true),
tool("count_posts", true),
tool("read_post_by_slug", true),
tool("get_post_translations", true),
tool("get_media_translations", true),
tool("upsert_media_translation", false),
tool("draft_post", false),
tool("propose_script", false),
tool("propose_template", false),
tool("propose_media_metadata", false),
tool("propose_post_metadata", false),
tool("accept_proposal", false),
tool("discard_proposal", false)
]
Enum.map(@tools, fn {name, read_only} -> tool(name, read_only) end)
end
@spec call(String.t(), map()) :: {:ok, term()} | {:error, term()}
@@ -97,12 +98,12 @@ defmodule BDS.MCP.Tools do
end
defp tool_metadata("check_term") do
%{
title: "Check Term",
description:
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or count_posts when unsure whether a term is a category or tag.",
input_schema: object_schema(%{"term" => string_schema("The term to look up")}, ["term"])
}
single_string_arg_tool_metadata(
"Check Term",
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or count_posts when unsure whether a term is a category or tag.",
"term",
"The term to look up"
)
end
defp tool_metadata("search_posts") do
@@ -150,21 +151,21 @@ defmodule BDS.MCP.Tools do
end
defp tool_metadata("get_post_translations") do
%{
title: "Get Post Translations",
description:
"List all translations available for a blog post, including language, title, excerpt, content, and status.",
input_schema: object_schema(%{"postId" => string_schema("The post ID")}, ["postId"])
}
single_string_arg_tool_metadata(
"Get Post Translations",
"List all translations available for a blog post, including language, title, excerpt, content, and status.",
"postId",
"The post ID"
)
end
defp tool_metadata("get_media_translations") do
%{
title: "Get Media Translations",
description:
"List all available translations for media metadata, including language, title, alt text, and captions.",
input_schema: object_schema(%{"mediaId" => string_schema("The media ID")}, ["mediaId"])
}
single_string_arg_tool_metadata(
"Get Media Translations",
"List all available translations for media metadata, including language, title, alt text, and captions.",
"mediaId",
"The media ID"
)
end
defp tool_metadata("upsert_media_translation") do
@@ -276,20 +277,28 @@ defmodule BDS.MCP.Tools do
end
defp tool_metadata("accept_proposal") do
%{
title: "Accept Proposal",
description: "Accept a pending proposal and apply or publish its changes.",
input_schema:
object_schema(%{"proposalId" => string_schema("The proposal ID")}, ["proposalId"])
}
single_string_arg_tool_metadata(
"Accept Proposal",
"Accept a pending proposal and apply or publish its changes.",
"proposalId",
"The proposal ID"
)
end
defp tool_metadata("discard_proposal") do
single_string_arg_tool_metadata(
"Discard Proposal",
"Discard a pending proposal and remove any temporary draft artifacts.",
"proposalId",
"The proposal ID"
)
end
defp single_string_arg_tool_metadata(title, description, field, field_description) do
%{
title: "Discard Proposal",
description: "Discard a pending proposal and remove any temporary draft artifacts.",
input_schema:
object_schema(%{"proposalId" => string_schema("The proposal ID")}, ["proposalId"])
title: title,
description: description,
input_schema: object_schema(%{field => string_schema(field_description)}, [field])
}
end

View File

@@ -1,6 +1,8 @@
defmodule BDS.Menu do
@moduledoc false
import BDS.MapUtils, only: [attr: 2]
alias BDS.Persistence
alias BDS.Projects
@@ -263,11 +265,4 @@ defmodule BDS.Menu do
|> String.replace(~s("), "&quot;")
end
defp attr(attrs, key) do
cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key)
Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key))
true -> nil
end
end
end

View File

@@ -1,6 +1,8 @@
defmodule BDS.Metadata do
@moduledoc false
import BDS.MapUtils, only: [attr: 2, attr: 3]
require Logger
alias BDS.Embeddings
@@ -674,19 +676,4 @@ defmodule BDS.Metadata do
defp maybe_backfill_embeddings(result, _project_id, _previous_state, _project_metadata),
do: result
defp attr(attrs, key) do
cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key)
Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key))
true -> nil
end
end
defp attr(attrs, key, default) do
cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key)
Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key))
true -> default
end
end
end

View File

@@ -3,6 +3,7 @@ defmodule BDS.Projects do
import Ecto.Query
alias BDS.MapUtils
alias BDS.Metadata
alias BDS.Persistence
alias BDS.Projects.Project
@@ -151,8 +152,8 @@ defmodule BDS.Projects do
@spec create_project(attrs()) :: {:ok, Project.t()} | {:error, term()}
def create_project(attrs) do
now = Persistence.now_ms()
name = attr(attrs, :name) || ""
slug = unique_slug(attr(attrs, :slug) || Slug.slugify(name))
name = MapUtils.attr(attrs, :name) || ""
slug = unique_slug(MapUtils.attr(attrs, :slug) || Slug.slugify(name))
retry_create_project(fn ->
Repo.transaction(fn ->
@@ -162,8 +163,8 @@ defmodule BDS.Projects do
id: Ecto.UUID.generate(),
name: name,
slug: slug,
description: attr(attrs, :description),
data_path: attr(attrs, :data_path),
description: MapUtils.attr(attrs, :description),
data_path: MapUtils.attr(attrs, :data_path),
created_at: now,
updated_at: now,
is_active: false
@@ -391,11 +392,4 @@ defmodule BDS.Projects do
File.write(path, Jason.encode!(registry))
end
defp attr(attrs, key) do
cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key)
Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key))
true -> nil
end
end
end

View File

@@ -85,29 +85,10 @@ defmodule BDS.Rendering.Filters do
case String.downcase(macro_name) do
"youtube" ->
render_macro_template(
"macros/youtube",
%{
"id" => Map.get(params, "id", ""),
"title" =>
default_macro_title(
Map.get(params, "title"),
language,
"YouTube video"
)
},
context
)
render_video_macro("youtube", params, language, "YouTube video", context)
"vimeo" ->
render_macro_template(
"macros/vimeo",
%{
"id" => Map.get(params, "id", ""),
"title" => default_macro_title(Map.get(params, "title"), language, "Vimeo video")
},
context
)
render_video_macro("vimeo", params, language, "Vimeo video", context)
"gallery" ->
render_gallery_macro(context, params, post_id)
@@ -199,9 +180,15 @@ defmodule BDS.Rendering.Filters do
end
defp render_markdown_html(markdown) do
case Earmark.as_html(markdown) do
{:ok, html, _messages} -> html
{:error, html, _messages} -> html
# Match Earmark's defaults: GFM tables, strikethrough and bare-URL autolinks
# were all on. Macros above inject raw HTML (gallery, video embeds), so
# unsafe: true keeps that HTML untouched as Earmark's no-escape path did.
case MDEx.to_html(markdown,
extension: [table: true, strikethrough: true, autolink: true],
render: [unsafe: true]
) do
{:ok, html} -> html
{:error, _reason} -> markdown
end
end
@@ -313,61 +300,15 @@ defmodule BDS.Rendering.Filters do
# ── Built-in macro renderers ───────────────────────────────────────────────
defp render_gallery_macro(context, params, post_id) when is_binary(post_id) do
columns = normalize_columns(Map.get(params, "columns", "3"), 3, 1, 6)
caption = Map.get(params, "caption")
defp render_gallery_macro(context, params, post_id) do
normalized_post_id = if is_binary(post_id), do: post_id, else: ""
items = if is_binary(post_id), do: gallery_items(post_id), else: []
items =
post_id
|> linked_media_images()
|> Enum.map(fn media ->
%{
"media_path" => "/#{media.file_path}",
"title" => media.title || media.original_name,
"alt" => media.alt || media.title || media.original_name,
"group_name" => post_id
}
end)
render_macro_template(
"macros/gallery",
%{
"columns" => columns,
"post_id" => post_id,
"items" => items,
"caption" => caption,
"empty_label" =>
BDS.Gettext.lgettext(
Access.get(context, "language") || "en",
"render",
"No images"
)
},
context
)
end
defp render_gallery_macro(context, params, _post_id) do
render_macro_template(
"macros/gallery",
%{
"columns" => normalize_columns(Map.get(params, "columns", "3"), 3, 1, 6),
"post_id" => "",
"items" => [],
"caption" => Map.get(params, "caption"),
"empty_label" =>
BDS.Gettext.lgettext(
Access.get(context, "language") || "en",
"render",
"No images"
)
},
context
)
render_gallery_template(context, params, normalized_post_id, items)
end
defp render_photo_archive_macro(context, params) do
language = Access.get(context, "language") || "en"
language = macro_language(context)
project_id = project_id_from_context(context)
months =
@@ -390,7 +331,7 @@ defmodule BDS.Rendering.Filters do
end
defp render_tag_cloud_macro(context, params) do
language = Access.get(context, "language") || "en"
language = macro_language(context)
project_id = project_id_from_context(context)
{words_json, width, height} =
@@ -414,6 +355,44 @@ defmodule BDS.Rendering.Filters do
)
end
defp render_video_macro(kind, params, language, translation, context) do
render_macro_template(
"macros/#{kind}",
%{
"id" => Map.get(params, "id", ""),
"title" => default_macro_title(Map.get(params, "title"), language, translation)
},
context
)
end
defp render_gallery_template(context, params, post_id, items) do
render_macro_template(
"macros/gallery",
%{
"columns" => normalize_columns(Map.get(params, "columns", "3"), 3, 1, 6),
"post_id" => post_id,
"items" => items,
"caption" => Map.get(params, "caption"),
"empty_label" => BDS.Gettext.lgettext(macro_language(context), "render", "No images")
},
context
)
end
defp gallery_items(post_id) do
post_id
|> linked_media_images()
|> Enum.map(fn media ->
%{
"media_path" => "/#{media.file_path}",
"title" => media.title || media.original_name,
"alt" => media.alt || media.title || media.original_name,
"group_name" => post_id
}
end)
end
# ── Data queries for macros ────────────────────────────────────────────────
defp linked_media_images(post_id) do
@@ -556,6 +535,8 @@ defmodule BDS.Rendering.Filters do
# ── Helpers ────────────────────────────────────────────────────────────────
defp macro_language(context), do: Access.get(context, "language") || "en"
defp project_id_from_context(context) do
post = Access.get(context, "post") || %{}
post["project_id"] || Access.get(post, :project_id) || project_id_from_post(context)

View File

@@ -39,52 +39,24 @@ defmodule BDS.Rendering.Labels do
@spec month_name(integer() | nil, String.t()) :: String.t() | nil
def month_name(nil, _language), do: nil
def month_name(1, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "January") end)
end
def month_name(2, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "February") end)
end
def month_name(3, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "March") end)
end
def month_name(4, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "April") end)
end
def month_name(5, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "May") end)
end
def month_name(6, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "June") end)
end
def month_name(7, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "July") end)
end
def month_name(8, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "August") end)
end
def month_name(9, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "September") end)
end
def month_name(10, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "October") end)
end
def month_name(11, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "November") end)
end
def month_name(12, language) do
Gettext.with_locale(BDS.Gettext, language, fn -> dgettext("render", "December") end)
def month_name(month, language) when month in 1..12 do
Gettext.with_locale(BDS.Gettext, language, fn ->
[
dgettext("render", "January"),
dgettext("render", "February"),
dgettext("render", "March"),
dgettext("render", "April"),
dgettext("render", "May"),
dgettext("render", "June"),
dgettext("render", "July"),
dgettext("render", "August"),
dgettext("render", "September"),
dgettext("render", "October"),
dgettext("render", "November"),
dgettext("render", "December")
]
|> Enum.at(month - 1)
end)
end
def month_name(_month, _language), do: nil

View File

@@ -41,15 +41,8 @@ defmodule BDS.Rendering.LinksAndLanguages do
def canonical_media_path_by_source_path(project_id) do
Repo.all(from media in MediaAsset, where: media.project_id == ^project_id)
|> Enum.reduce(%{}, fn media, acc ->
datetime = Persistence.from_unix_ms!(media.created_at)
source_key =
Path.join([
"media",
Integer.to_string(datetime.year),
String.pad_leading(Integer.to_string(datetime.month), 2, "0"),
media.original_name
])
Path.join(["media" | year_month_path_parts(media.created_at)] ++ [media.original_name])
|> String.downcase()
Map.put(acc, source_key, Path.join("/", media.file_path))
@@ -57,24 +50,8 @@ defmodule BDS.Rendering.LinksAndLanguages do
end
@spec post_path(map(), String.t() | nil) :: String.t()
def post_path(post, language_prefix)
when is_binary(language_prefix) and language_prefix != "" do
String.trim_trailing(language_prefix, "/") <> post_path(post, nil)
end
def post_path(post, ""), do: post_path(post, nil)
def post_path(post, nil) do
datetime = Persistence.from_unix_ms!(post.created_at)
Path.join([
"/",
Integer.to_string(datetime.year),
String.pad_leading(Integer.to_string(datetime.month), 2, "0"),
String.pad_leading(Integer.to_string(datetime.day), 2, "0"),
post.slug
]) <> "/"
end
def post_path(post, language_prefix),
do: normalize_language_prefix(language_prefix) <> base_post_path(post)
@spec post_path(map(), String.t() | nil, String.t()) :: String.t()
def post_path(post, language, main_language) do
@@ -136,4 +113,30 @@ defmodule BDS.Rendering.LinksAndLanguages do
|> String.split("-", parts: 2)
|> hd()
end
defp base_post_path(post) do
"/" <> Path.join(post_date_path_parts(post.created_at) ++ [post.slug]) <> "/"
end
defp post_date_path_parts(created_at) do
datetime = Persistence.from_unix_ms!(created_at)
year_month_path_parts(datetime) ++ [pad2(datetime.day)]
end
defp year_month_path_parts(created_at) when is_integer(created_at) do
created_at
|> Persistence.from_unix_ms!()
|> year_month_path_parts()
end
defp year_month_path_parts(%DateTime{} = datetime) do
[Integer.to_string(datetime.year), pad2(datetime.month)]
end
defp normalize_language_prefix(prefix) when is_binary(prefix) and prefix != "",
do: String.trim_trailing(prefix, "/")
defp normalize_language_prefix(_prefix), do: ""
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
end

View File

@@ -55,34 +55,22 @@ defmodule BDS.Rendering.ListArchive do
%{
language: language,
language_prefix:
Map.get(
assigns,
:language_prefix,
Map.get(
assigns,
"language_prefix",
LinksAndLanguages.language_prefix(language, main_language)
)
),
language_prefix: assign_override(
assigns,
:language_prefix,
"language_prefix",
LinksAndLanguages.language_prefix(language, main_language)
),
page_title: MapUtils.attr(assigns, :page_title),
posts: posts,
pico_stylesheet_href:
Map.get(
assigns,
:pico_stylesheet_href,
Map.get(
assigns,
"pico_stylesheet_href",
RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)
)
),
pico_stylesheet_href: assign_override(
assigns,
:pico_stylesheet_href,
"pico_stylesheet_href",
RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)
),
html_theme_attribute:
Map.get(
assigns,
:html_theme_attribute,
Map.get(assigns, "html_theme_attribute")
),
assign_override(assigns, :html_theme_attribute, "html_theme_attribute", nil),
blog_languages: RenderMetadata.blog_languages(metadata, language),
alternate_links: [],
menu_items: RenderMetadata.menu_items(project_id),
@@ -126,58 +114,36 @@ defmodule BDS.Rendering.ListArchive do
%{
page_title: MapUtils.attr(assigns, :page_title, "404"),
language: language,
language_prefix:
Map.get(
assigns,
:language_prefix,
Map.get(
assigns,
"language_prefix",
LinksAndLanguages.language_prefix(language, main_language)
)
),
pico_stylesheet_href:
Map.get(
assigns,
:pico_stylesheet_href,
Map.get(
assigns,
"pico_stylesheet_href",
RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)
)
),
language_prefix: assign_override(
assigns,
:language_prefix,
"language_prefix",
LinksAndLanguages.language_prefix(language, main_language)
),
pico_stylesheet_href: assign_override(
assigns,
:pico_stylesheet_href,
"pico_stylesheet_href",
RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)
),
html_theme_attribute:
Map.get(
assigns,
:html_theme_attribute,
Map.get(assigns, "html_theme_attribute")
),
assign_override(assigns, :html_theme_attribute, "html_theme_attribute", nil),
blog_languages: RenderMetadata.blog_languages(metadata, language),
menu_items: RenderMetadata.menu_items(project_id),
alternate_links: [],
not_found_message:
Map.get(
assign_override(
assigns,
:not_found_message,
Map.get(
assigns,
"not_found_message",
BDS.Gettext.lgettext(
language,
"render",
"The requested preview page could not be found."
)
)
"not_found_message",
BDS.Gettext.lgettext(language, "render", "The requested preview page could not be found.")
),
not_found_back_label:
Map.get(
assign_override(
assigns,
:not_found_back_label,
Map.get(
assigns,
"not_found_back_label",
BDS.Gettext.lgettext(language, "render", "Back to preview home")
)
"not_found_back_label",
BDS.Gettext.lgettext(language, "render", "Back to preview home")
),
labels: Labels.for_language(language)
}
@@ -215,28 +181,23 @@ defmodule BDS.Rendering.ListArchive do
template_context
),
raw_content: raw_content,
excerpt: MapUtils.attr(post, :excerpt, Map.get(post_record || %{}, :excerpt)),
author: MapUtils.attr(post, :author, Map.get(post_record || %{}, :author)),
language:
Map.get(
post,
:language,
Map.get(post_record || %{}, :language)
),
excerpt: MapUtils.attr(post, :excerpt, record_value(post_record, :excerpt)),
author: MapUtils.attr(post, :author, record_value(post_record, :author)),
language: Map.get(post, :language, record_value(post_record, :language)),
published_at:
MapUtils.attr(post, :published_at, Map.get(post_record || %{}, :published_at)),
created_at: MapUtils.attr(post, :created_at, Map.get(post_record || %{}, :created_at)),
updated_at: MapUtils.attr(post, :updated_at, Map.get(post_record || %{}, :updated_at)),
tags: MapUtils.attr(post, :tags, Map.get(post_record || %{}, :tags, [])) || [],
MapUtils.attr(post, :published_at, record_value(post_record, :published_at)),
created_at: MapUtils.attr(post, :created_at, record_value(post_record, :created_at)),
updated_at: MapUtils.attr(post, :updated_at, record_value(post_record, :updated_at)),
tags: MapUtils.attr(post, :tags, record_list(post_record, :tags)) || [],
categories:
MapUtils.attr(post, :categories, Map.get(post_record || %{}, :categories, [])) || [],
MapUtils.attr(post, :categories, record_list(post_record, :categories)) || [],
template_slug:
MapUtils.attr(post, :template_slug, Map.get(post_record || %{}, :template_slug)),
MapUtils.attr(post, :template_slug, record_value(post_record, :template_slug)),
do_not_translate:
MapUtils.attr(
post,
:do_not_translate,
Map.get(post_record || %{}, :do_not_translate, false)
record_value(post_record, :do_not_translate, false)
),
href: MapUtils.attr(post, :href),
show_title: true,
@@ -349,4 +310,14 @@ defmodule BDS.Rendering.ListArchive do
do: RenderMetadata.calendar_initial_month(post)
defp calendar_initial_month_from_posts([]), do: nil
defp assign_override(assigns, atom_key, string_key, default) do
Map.get(assigns, atom_key, Map.get(assigns, string_key, default))
end
defp record_value(post_record, key, default \\ nil) do
Map.get(post_record || %{}, key, default)
end
defp record_list(post_record, key), do: record_value(post_record, key, []) || []
end

View File

@@ -14,6 +14,11 @@ defmodule BDS.Rendering.Metadata do
alias BDS.Posts.Translation
alias BDS.Tags.Tag
@menu_item_path_segments %{
page: [],
category_archive: ["category"]
}
@spec project_metadata(String.t()) :: map()
def project_metadata(project_id) do
{:ok, metadata} = ProjectMetadata.get_project_metadata(project_id)
@@ -23,17 +28,19 @@ defmodule BDS.Rendering.Metadata do
@spec menu_items(String.t()) :: [map()]
def menu_items(project_id) do
{:ok, %{items: items}} = Menu.get_menu(project_id)
Enum.map(items, &to_template_menu_item/1)
template_menu_items(items)
end
@spec menu_items_from_raw([map()]) :: [map()]
def menu_items_from_raw(items) when is_list(items) do
Enum.map(items, &to_template_menu_item/1)
template_menu_items(items)
end
defp template_menu_items(items), do: Enum.map(items, &to_template_menu_item/1)
defp to_template_menu_item(item) do
kind = Map.get(item, :kind)
children = Enum.map(Map.get(item, :children, []), &to_template_menu_item/1)
children = template_menu_items(Map.get(item, :children, []))
%{
title: Map.get(item, :label, ""),
@@ -46,13 +53,13 @@ defmodule BDS.Rendering.Metadata do
defp menu_item_href(%{kind: :home}), do: "/"
defp menu_item_href(%{kind: :page, slug: slug}) when is_binary(slug) and slug != "",
do: "/#{URI.encode(slug)}/"
defp menu_item_href(%{kind: kind, slug: slug}) when is_binary(slug) and slug != "" do
case Map.get(@menu_item_path_segments, kind) do
nil -> "#"
path_segments -> "/" <> Path.join(path_segments ++ [URI.encode(slug)]) <> "/"
end
end
defp menu_item_href(%{kind: :category_archive, slug: slug}) when is_binary(slug) and slug != "",
do: "/category/#{URI.encode(slug)}/"
defp menu_item_href(%{kind: :submenu}), do: "#"
defp menu_item_href(_item), do: "#"
@spec blog_languages(map(), String.t()) :: [map()]

View File

@@ -26,8 +26,8 @@ defmodule BDS.Rendering.PostRendering do
post_record = Map.get(assigns, :_post_record) || load_post_record(assigns)
canonical_post = canonical_post_record(post_record)
post_id = canonical_post_id(post_record, assigns)
post_categories = Map.get(post_record || %{}, :categories, []) || []
post_tags = Map.get(post_record || %{}, :tags, []) || []
post_categories = record_list(post_record, :categories)
post_tags = record_list(post_record, :tags)
canonical_post_paths =
LinksAndLanguages.canonical_post_path_by_slug(project_id, main_language)
@@ -58,38 +58,26 @@ defmodule BDS.Rendering.PostRendering do
%{
language: language,
language_prefix:
Map.get(
assigns,
:language_prefix,
Map.get(
assigns,
"language_prefix",
LinksAndLanguages.language_prefix(language, main_language)
)
),
language_prefix: assign_override(
assigns,
:language_prefix,
"language_prefix",
LinksAndLanguages.language_prefix(language, main_language)
),
page_title:
Map.get(
assigns,
:page_title,
MapUtils.attr(assigns, :title)
),
pico_stylesheet_href:
Map.get(
assigns,
:pico_stylesheet_href,
Map.get(
assigns,
"pico_stylesheet_href",
RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)
)
),
pico_stylesheet_href: assign_override(
assigns,
:pico_stylesheet_href,
"pico_stylesheet_href",
RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)
),
html_theme_attribute:
Map.get(
assigns,
:html_theme_attribute,
Map.get(assigns, "html_theme_attribute")
),
assign_override(assigns, :html_theme_attribute, "html_theme_attribute", nil),
blog_languages: RenderMetadata.blog_languages(metadata, language),
alternate_links: RenderMetadata.alternate_links(canonical_post, project_id, main_language),
menu_items: RenderMetadata.menu_items(project_id),
@@ -211,33 +199,33 @@ defmodule BDS.Rendering.PostRendering do
title: MapUtils.attr(assigns, :title),
content: MapUtils.attr(assigns, :content),
raw_content: MapUtils.attr(assigns, :raw_content),
project_id: MapUtils.attr(assigns, :project_id) || Map.get(post_record || %{}, :project_id),
project_id: MapUtils.attr(assigns, :project_id) || record_value(post_record, :project_id),
excerpt:
Map.get(
assigns,
:excerpt,
Map.get(post_record || %{}, :excerpt)
record_value(post_record, :excerpt)
),
author: Map.get(post_record || %{}, :author),
author: record_value(post_record, :author),
language:
Map.get(
assigns,
:language,
Map.get(post_record || %{}, :language)
record_value(post_record, :language)
),
show_title: true,
published_at: Map.get(post_record || %{}, :published_at),
created_at: Map.get(post_record || %{}, :created_at),
updated_at: Map.get(post_record || %{}, :updated_at),
tags: Map.get(post_record || %{}, :tags, []) || [],
categories: Map.get(post_record || %{}, :categories, []) || [],
published_at: record_value(post_record, :published_at),
created_at: record_value(post_record, :created_at),
updated_at: record_value(post_record, :updated_at),
tags: record_list(post_record, :tags),
categories: record_list(post_record, :categories),
template_slug:
Map.get(
post_record || %{},
:template_slug,
MapUtils.attr(assigns, :template_slug)
),
do_not_translate: Map.get(post_record || %{}, :do_not_translate, false),
do_not_translate: record_value(post_record, :do_not_translate, false),
linked_media: linked_media_images(assigns),
outgoing_links: outgoing_links,
incoming_links: incoming_links
@@ -287,4 +275,14 @@ defmodule BDS.Rendering.PostRendering do
[]
end
end
defp assign_override(assigns, atom_key, string_key, default) do
Map.get(assigns, atom_key, Map.get(assigns, string_key, default))
end
defp record_value(post_record, key, default \\ nil) do
Map.get(post_record || %{}, key, default)
end
defp record_list(post_record, key), do: record_value(post_record, key, []) || []
end

View File

@@ -70,40 +70,39 @@ defmodule BDS.Rendering.TemplateSelection do
end
end
defp select_template(project_id, kind, slug) when is_binary(slug) and slug != "" do
Repo.one(
from template in Template,
where:
template.project_id == ^project_id and template.kind == ^kind and
template.status == :published and
template.enabled == true and template.slug == ^slug,
limit: 1
)
defp select_template(project_id, kind, slug) do
project_id
|> published_template_query(kind)
|> select_template_scope(kind, slug)
|> Repo.one()
end
defp select_template(project_id, :post, nil) do
defp published_template_query(project_id, kind) do
from template in Template,
where:
template.project_id == ^project_id and template.kind == ^kind and
template.status == :published and
template.enabled == true
end
defp select_template_scope(query, _kind, slug) when is_binary(slug) and slug != "" do
from template in query,
where: template.slug == ^slug,
limit: 1
end
defp select_template_scope(query, :post, nil) do
default_slug = StarterTemplates.default_slug(:post)
Repo.one(
from template in Template,
where:
template.project_id == ^project_id and template.kind == :post and
template.status == :published and
template.enabled == true and template.slug == ^default_slug,
limit: 1
)
from template in query,
where: template.slug == ^default_slug,
limit: 1
end
defp select_template(project_id, kind, nil) do
Repo.one(
from template in Template,
where:
template.project_id == ^project_id and template.kind == ^kind and
template.status == :published and
template.enabled == true,
order_by: [desc: template.created_at, desc: template.slug],
limit: 1
)
defp select_template_scope(query, _kind, nil) do
from template in query,
order_by: [desc: template.created_at, desc: template.slug],
limit: 1
end
defp published_template_body(%Template{content: content}) when is_binary(content),

View File

@@ -6,7 +6,7 @@ defmodule BDS.Scripting do
require Logger
alias BDS.Scripting.Capabilities
alias BDS.Scripting.Runtime
alias BDS.Scripting.Lua
@type job_status :: :queued | :running | :completed | :failed | :cancelled
@type job_snapshot :: %{
@@ -31,7 +31,7 @@ defmodule BDS.Scripting do
runtime().validate(source)
end
@spec execute(String.t(), String.t(), [term()], [Runtime.execution_option()]) ::
@spec execute(String.t(), String.t(), [term()], [Lua.execution_option()]) ::
{:ok, term()} | {:error, term()}
def execute(source, entrypoint, args \\ [], opts \\ [])
when is_binary(source) and is_binary(entrypoint) and is_list(args) and is_list(opts) do
@@ -39,7 +39,7 @@ defmodule BDS.Scripting do
end
@spec execute_project_script(String.t(), String.t(), String.t(), [term()], [
Runtime.execution_option()
Lua.execution_option()
]) ::
{:ok, term()} | {:error, term()}
def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ [])
@@ -75,7 +75,7 @@ defmodule BDS.Scripting do
end
end
@spec start_job(String.t(), String.t(), [term()], [Runtime.execution_option()]) ::
@spec start_job(String.t(), String.t(), [term()], [Lua.execution_option()]) ::
{:ok, job_snapshot()} | {:error, term()}
def start_job(source, entrypoint, args \\ [], opts \\ [])
when is_binary(source) and is_binary(entrypoint) and is_list(args) and is_list(opts) do

View File

@@ -273,65 +273,39 @@ defmodule BDS.Scripting.Capabilities.Util do
@spec zero_or_one_arg((term() -> term())) :: (list(), tuple() -> {list(), tuple()})
def zero_or_one_arg(callback) when is_function(callback, 1) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
value = callback.(normalize_input(decoded_args))
:luerl.encode_list([sanitize(value)], state)
end
luerl_callback(callback, fn decoded_args -> [normalize_input(decoded_args)] end)
end
@spec one_arg((term() -> term())) :: (list(), tuple() -> {list(), tuple()})
def one_arg(callback) when is_function(callback, 1) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
value =
case decoded_args do
[first | _rest] -> callback.(normalize_input(first))
[] -> callback.(nil)
end
:luerl.encode_list([sanitize(value)], state)
end
luerl_callback(callback, &normalized_callback_args(&1, 1))
end
@spec two_arg((term(), term() -> term())) :: (list(), tuple() -> {list(), tuple()})
def two_arg(callback) when is_function(callback, 2) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
value =
case decoded_args do
[first, second | _rest] -> callback.(normalize_input(first), normalize_input(second))
[first] -> callback.(normalize_input(first), nil)
[] -> callback.(nil, nil)
end
:luerl.encode_list([sanitize(value)], state)
end
luerl_callback(callback, &normalized_callback_args(&1, 2))
end
@spec three_arg((term(), term(), term() -> term())) :: (list(), tuple() -> {list(), tuple()})
def three_arg(callback) when is_function(callback, 3) do
luerl_callback(callback, &normalized_callback_args(&1, 3))
end
defp luerl_callback(callback, argument_builder) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
value =
case decoded_args do
[first, second, third | _rest] ->
callback.(normalize_input(first), normalize_input(second), normalize_input(third))
[first, second] ->
callback.(normalize_input(first), normalize_input(second), nil)
[first] ->
callback.(normalize_input(first), nil, nil)
[] ->
callback.(nil, nil, nil)
end
value = apply(callback, argument_builder.(decoded_args))
:luerl.encode_list([sanitize(value)], state)
end
end
defp normalized_callback_args(decoded_args, count) do
decoded_args
|> Enum.map(&normalize_input/1)
|> Enum.take(count)
|> pad_callback_args(count)
end
defp pad_callback_args(args, count),
do: args ++ List.duplicate(nil, max(count - length(args), 0))
end

View File

@@ -1,36 +1,50 @@
defmodule BDS.Scripting.JobStore do
@moduledoc false
use GenServer
use Agent
@spec start_link(term()) :: GenServer.on_start()
@spec start_link(term()) :: Agent.on_start()
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
Agent.start_link(fn -> %{jobs: %{}, runners: %{}} end, name: __MODULE__)
end
@spec put_job(map()) :: :ok
def put_job(job) when is_map(job) do
GenServer.call(__MODULE__, {:put_job, job})
update_state(fn %{jobs: jobs} = state ->
%{state | jobs: Map.put(jobs, job.id, job)}
end)
end
@spec update_job(String.t(), map()) :: :ok
def update_job(job_id, attrs) when is_binary(job_id) and is_map(attrs) do
GenServer.call(__MODULE__, {:update_job, job_id, attrs})
update_state(fn %{jobs: jobs} = state ->
next_jobs =
Map.update(jobs, job_id, nil, fn
nil -> nil
job -> Map.merge(job, attrs)
end)
%{state | jobs: next_jobs}
end)
end
@spec attach_runner(String.t(), pid()) :: :ok
def attach_runner(job_id, pid) when is_binary(job_id) and is_pid(pid) do
GenServer.call(__MODULE__, {:attach_runner, job_id, pid})
update_state(fn %{runners: runners} = state ->
%{state | runners: Map.put(runners, job_id, pid)}
end)
end
@spec detach_runner(String.t()) :: :ok
def detach_runner(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:detach_runner, job_id})
update_state(fn %{runners: runners} = state ->
%{state | runners: Map.delete(runners, job_id)}
end)
end
@spec fetch_job(String.t()) :: map() | nil
def fetch_job(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:fetch_job, job_id})
get_state(&Map.get(&1.jobs, job_id))
end
@spec fetch_job!(String.t()) :: map()
@@ -43,44 +57,9 @@ defmodule BDS.Scripting.JobStore do
@spec runner_for(String.t()) :: pid() | nil
def runner_for(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:runner_for, job_id})
get_state(&Map.get(&1.runners, job_id))
end
@impl true
def init(_state) do
{:ok, %{jobs: %{}, runners: %{}}}
end
@impl true
def handle_call({:put_job, %{id: job_id} = job}, _from, state) do
next_state = put_in(state, [:jobs, job_id], job)
{:reply, :ok, next_state}
end
def handle_call({:update_job, job_id, attrs}, _from, state) do
next_state =
update_in(state, [:jobs, job_id], fn
nil -> nil
job -> Map.merge(job, attrs)
end)
{:reply, :ok, next_state}
end
def handle_call({:attach_runner, job_id, pid}, _from, state) do
next_state = put_in(state, [:runners, job_id], pid)
{:reply, :ok, next_state}
end
def handle_call({:detach_runner, job_id}, _from, state) do
{:reply, :ok, %{state | runners: Map.delete(state.runners, job_id)}}
end
def handle_call({:fetch_job, job_id}, _from, state) do
{:reply, Map.get(state.jobs, job_id), state}
end
def handle_call({:runner_for, job_id}, _from, state) do
{:reply, Map.get(state.runners, job_id), state}
end
defp get_state(fun), do: Agent.get(__MODULE__, fun)
defp update_state(fun), do: Agent.update(__MODULE__, fun)
end

View File

@@ -6,7 +6,23 @@ defmodule BDS.Scripting.Lua do
and opt-in.
"""
@behaviour BDS.Scripting.Runtime
@type source :: String.t()
@type entrypoint :: String.t()
@type args :: [term()]
@type progress_event :: map()
@type progress_callback :: (progress_event() -> any())
@type execution_option ::
{:timeout, non_neg_integer() | :infinity}
| {:max_reductions, pos_integer() | :none}
| {:spawn_opts, [term()]}
| {:on_progress, progress_callback()}
| {:capabilities, map()}
@callback validate(source()) :: :ok | {:error, term()}
@callback execute(source(), entrypoint(), args(), [execution_option()]) ::
{:ok, term()} | {:error, term()}
@behaviour __MODULE__
@type lua_state :: tuple()
@type runtime_result :: {:ok, term(), lua_state} | {:error, term()}

View File

@@ -1,24 +0,0 @@
defmodule BDS.Scripting.Runtime do
@moduledoc """
Behaviour for user-script runtimes hosted by bDS.
The runtime boundary is intentionally narrow: syntax validation and
bounded entrypoint execution.
"""
@type source :: String.t()
@type entrypoint :: String.t()
@type args :: [term()]
@type progress_event :: map()
@type progress_callback :: (progress_event() -> any())
@type execution_option ::
{:timeout, non_neg_integer() | :infinity}
| {:max_reductions, pos_integer() | :none}
| {:spawn_opts, [term()]}
| {:on_progress, progress_callback()}
| {:capabilities, map()}
@callback validate(source()) :: :ok | {:error, term()}
@callback execute(source(), entrypoint(), args(), [execution_option()]) ::
{:ok, term()} | {:error, term()}
end

View File

@@ -3,6 +3,7 @@ defmodule BDS.Tags do
import Ecto.Query
alias BDS.MapUtils
alias BDS.Persistence
alias BDS.Posts
alias BDS.Posts.Post
@@ -16,8 +17,8 @@ defmodule BDS.Tags do
@spec create_tag(attrs()) :: tag_result()
def create_tag(attrs) do
project_id = attr(attrs, :project_id)
name = attr(attrs, :name) |> to_string() |> String.trim()
project_id = MapUtils.attr(attrs, :project_id)
name = MapUtils.attr(attrs, :name) |> to_string() |> String.trim()
with :ok <- validate_unique_name(project_id, name) do
now = Persistence.now_ms()
@@ -27,8 +28,8 @@ defmodule BDS.Tags do
id: Ecto.UUID.generate(),
project_id: project_id,
name: name,
color: attr(attrs, :color),
post_template_slug: attr(attrs, :post_template_slug),
color: MapUtils.attr(attrs, :color),
post_template_slug: MapUtils.attr(attrs, :post_template_slug),
created_at: now,
updated_at: now
})
@@ -115,8 +116,8 @@ defmodule BDS.Tags do
tag ->
updates = %{
color: attr(attrs, :color),
post_template_slug: attr(attrs, :post_template_slug),
color: MapUtils.attr(attrs, :color),
post_template_slug: MapUtils.attr(attrs, :post_template_slug),
updated_at: Persistence.now_ms()
}
@@ -265,8 +266,11 @@ defmodule BDS.Tags do
|> Enum.sort_by(&String.downcase(&1.name))
|> Enum.map(fn tag ->
%{"name" => tag.name}
|> maybe_put("color", tag.color)
|> maybe_put("postTemplateSlug", tag.post_template_slug)
|> BDS.MapUtils.maybe_put("color", BDS.MapUtils.blank_to_nil(tag.color))
|> BDS.MapUtils.maybe_put(
"postTemplateSlug",
BDS.MapUtils.blank_to_nil(tag.post_template_slug)
)
end)
Persistence.atomic_write(path, Jason.encode!(payload, pretty: true))
@@ -385,15 +389,4 @@ defmodule BDS.Tags do
:ok
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp attr(attrs, key) do
cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key)
Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key))
true -> nil
end
end
end

View File

@@ -373,8 +373,8 @@ defmodule BDS.Tasks do
status: status,
progress: nil,
message: nil,
group_id: attr(attrs, :group_id),
group_name: attr(attrs, :group_name),
group_id: BDS.MapUtils.attr(attrs, :group_id),
group_name: BDS.MapUtils.attr(attrs, :group_name),
created_at: DateTime.utc_now(),
started_at: nil,
finished_at: nil,
@@ -570,11 +570,4 @@ defmodule BDS.Tasks do
|> Keyword.get(:finished_task_ttl_ms, @default_finished_task_ttl_ms)
end
defp attr(attrs, key) do
cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key)
Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key))
true -> nil
end
end
end

View File

@@ -329,9 +329,9 @@ defmodule BDS.WxrParser do
slug: item.post_name,
content: item.content,
excerpt: item.excerpt,
pub_date: blank_to_nil(item.pub_date),
post_date: blank_to_nil(item.post_date),
post_modified: blank_to_nil(item.post_modified),
pub_date: BDS.MapUtils.blank_to_nil(item.pub_date),
post_date: BDS.MapUtils.blank_to_nil(item.post_date),
post_modified: BDS.MapUtils.blank_to_nil(item.post_modified),
creator: item.creator,
status: item.status,
post_type: item.post_type,
@@ -342,7 +342,7 @@ defmodule BDS.WxrParser do
defp parse_media_item(item) do
attachment_url = item.attachment_url
filename = attachment_url |> Path.basename() |> blank_to_nil() || ""
filename = attachment_url |> Path.basename() |> BDS.MapUtils.blank_to_nil() || ""
%{
wp_id: parse_integer(item.post_id),
@@ -350,7 +350,7 @@ defmodule BDS.WxrParser do
url: attachment_url,
filename: filename,
relative_path: relative_upload_path(attachment_url),
pub_date: blank_to_nil(item.pub_date),
pub_date: BDS.MapUtils.blank_to_nil(item.pub_date),
parent_id: parse_integer(item.post_parent),
mime_type: MIME.from_path(filename),
description: item.content
@@ -373,6 +373,4 @@ defmodule BDS.WxrParser do
end
end
defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value
end

View File

@@ -32,7 +32,7 @@ defmodule BDS.MixProject do
{:ecto_sqlite3, "~> 0.21"},
{:luerl, "~> 1.5"},
{:jason, "~> 1.4"},
{:earmark, "~> 1.4"},
{:mdex, "~> 0.13"},
{:liquex, "~> 0.13.1"},
{:plug, "~> 1.18"},
{:bandit, "~> 1.5"},

View File

@@ -15,7 +15,6 @@
"decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
"desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
@@ -43,6 +42,8 @@
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"},
"luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"},
"mdex": {:hex, :mdex, "0.13.1", "2af3da1e0ec1594d9fc4c0134031a0b48397092963eef87448a7a1c2b9332663", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.2", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "99e934e977ea4045914870616baae8d4e1513aa0309f769ed65a681ebe5e0f02"},
"mdex_native": {:hex, :mdex_native, "0.2.2", "adc230643a173b4a2b3593c450f56c41e51714c51f59bb8e528993810373cb92", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f16b0548dca63b91c134316cc89c1160119e8f308db57bcc5fc058206a7df32d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},

View File

@@ -1,6 +1,13 @@
defmodule BDS.AI.ChatStreamingTest do
use ExUnit.Case, async: false
defmodule FakeSecretBackend do
def encrypt(value), do: {:ok, "enc:" <> value}
def decrypt("enc:" <> value), do: {:ok, value}
def decrypt(_other), do: {:error, :invalid_ciphertext}
end
defmodule StreamingChatPlug do
import Plug.Conn
@@ -119,7 +126,8 @@ defmodule BDS.AI.ChatStreamingTest do
assert {:ok, reply} =
BDS.AI.send_chat_message(conversation_id, "tell me a story",
event_target: self()
event_target: self(),
secret_backend: FakeSecretBackend
)
assert reply.assistant_message.content == "Once upon a time"
@@ -148,7 +156,10 @@ defmodule BDS.AI.ChatStreamingTest do
task =
Task.async(fn ->
BDS.AI.send_chat_message(conversation_id, "stream forever", event_target: test_pid)
BDS.AI.send_chat_message(conversation_id, "stream forever",
event_target: test_pid,
secret_backend: FakeSecretBackend
)
end)
# Wait until tokens are actually flowing before cancelling.
@@ -167,7 +178,7 @@ defmodule BDS.AI.ChatStreamingTest do
url: "http://127.0.0.1:#{port}/v1",
api_key: "sk-stream",
model: "stream-model"
})
}, secret_backend: FakeSecretBackend)
assert :ok = BDS.AI.put_model_preference(:chat, "stream-model")
assert :ok = BDS.AI.set_airplane_mode(false)

View File

@@ -5465,6 +5465,18 @@ defmodule BDS.Desktop.ShellLiveTest do
refute html =~ ~r/<img\b/
end
test "chat editor renders GFM markdown and escapes raw HTML" do
{:safe, html} =
BDS.Desktop.ShellLive.ChatEditor.markdown_html(
"~~old~~ value\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n<script>alert(1)</script>"
)
assert html =~ "<del>old</del>"
assert html =~ "<table>"
refute html =~ "<script>"
assert html =~ "&lt;script&gt;"
end
test "chat editor renders chart inline surfaces from render_chart tool calls" do
assert {:ok, conversation} = AI.start_chat(%{title: "Chart Chat", model: "gpt-4.1"})

View File

@@ -169,13 +169,6 @@ defmodule BDS.DesktopTest do
assert_receive :quit_requested
end
test "desktop external links point at the bDS2 GitHub project and issue tracker" do
assert BDS.Desktop.ExternalLinks.github_url() == "https://github.com/rfc1437/bDS2"
assert BDS.Desktop.ExternalLinks.github_issues_url() ==
"https://github.com/rfc1437/bDS2/issues"
end
test "icon menu quit requests app-owned shutdown" do
previous_module = Application.get_env(:bds, :desktop_shutdown_module)
previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid)

View File

@@ -171,6 +171,37 @@ defmodule BDS.GalleryPipelineTest do
assert result =~ "tag-cloud-empty"
end
test "renders GFM tables, strikethrough and autolinks like Earmark did", %{
project: project,
post: post
} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
markdown = """
| A | B |
|---|---|
| 1 | 2 |
~~gone~~ and visit https://example.com now
"""
result =
BDS.Rendering.Filters.render_markdown(
markdown,
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "<table>"
assert result =~ "<del>gone</del>"
assert result =~ ~s(<a href="https://example.com">)
end
test "[[photo_archive]] renders without media", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"

View File

@@ -2,7 +2,7 @@ defmodule BDS.Scripting.JobTest do
use ExUnit.Case, async: false
defmodule FakeRuntime do
@behaviour BDS.Scripting.Runtime
@behaviour BDS.Scripting.Lua
@impl true
def validate(_source), do: :ok
@@ -19,7 +19,7 @@ defmodule BDS.Scripting.JobTest do
end
defmodule BlockingRuntime do
@behaviour BDS.Scripting.Runtime
@behaviour BDS.Scripting.Lua
@impl true
def validate(_source), do: :ok
@@ -37,7 +37,7 @@ defmodule BDS.Scripting.JobTest do
end
defmodule ShutdownAwareRuntime do
@behaviour BDS.Scripting.Runtime
@behaviour BDS.Scripting.Lua
@impl true
def validate(_source), do: :ok

View File

@@ -16,7 +16,11 @@ Enum.each(["LC_ALL", "LC_MESSAGES", "LANG"], fn variable ->
System.put_env(variable, "en_US.UTF-8")
end)
ExUnit.start()
# The test suite spins up long-lived LiveView, Bandit, and desktop automation
# processes against a single SQLite database. Running files concurrently causes
# intermittent `Database busy` failures in otherwise passing tests, so the
# default suite runs serially.
ExUnit.start(max_cases: 1)
ExUnit.after_suite(fn _results ->
File.rm_rf(cache_root)