Compare commits
40 Commits
8f073bbebd
...
cea47f10e2
| Author | SHA1 | Date | |
|---|---|---|---|
| cea47f10e2 | |||
| e1abe457e8 | |||
| 562eed2b98 | |||
| 7c38061c1c | |||
| 67a1f8a5f0 | |||
| d3c21247e0 | |||
| cd4a01a2c8 | |||
| b93b650689 | |||
| e284f030ae | |||
| 4c9c432d33 | |||
| 64ca5d4637 | |||
| a89175e230 | |||
| 26f06f3298 | |||
| 6a7a8c4288 | |||
| 62f87b8ab4 | |||
| ff777e590f | |||
| 7f1dbc390c | |||
| 59c236d871 | |||
| c3a8a8a755 | |||
| 2e61059568 | |||
| 936a20a03e | |||
| 0da149e105 | |||
| dc6fc4c631 | |||
| d3e2397217 | |||
| 3b14c1ecf3 | |||
| bb58f98afd | |||
| 66c24b565f | |||
| ccfaa4afe6 | |||
| f46239188a | |||
| 4a8a420c62 | |||
| b3d69f0291 | |||
| f8f6a242d6 | |||
| 341c1bf8ee | |||
| 4f6f7a6e1e | |||
| 72f6cc7d97 | |||
| f52e33760e | |||
| e87c35e256 | |||
| 2d98780007 | |||
| 26c481ea1f | |||
| 65d89a198e |
@@ -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
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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("), """)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
mix.exs
2
mix.exs
@@ -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"},
|
||||
|
||||
3
mix.lock
3
mix.lock
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =~ "<script>"
|
||||
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"})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user