chore: added more @spec

This commit is contained in:
2026-05-01 17:49:50 +02:00
parent abcae1dad7
commit 881056eb61
157 changed files with 6223 additions and 1647 deletions

View File

@@ -17,7 +17,9 @@ defmodule BDS.AI.CatalogProvider do
def changeset(provider, attrs) do
provider
|> cast(attrs, [:id, :name, :env_keys, :package_ref, :api_url, :doc_url, :updated_at], empty_values: [nil])
|> cast(attrs, [:id, :name, :env_keys, :package_ref, :api_url, :doc_url, :updated_at],
empty_values: [nil]
)
|> validate_required([:id, :name, :updated_at])
end
end

View File

@@ -25,7 +25,9 @@ defmodule BDS.AI.ChatConversation do
def changeset(conversation, attrs) do
conversation
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at], empty_values: [nil])
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at],
empty_values: [nil]
)
|> validate_required([:id, :title, :created_at, :updated_at])
end
end

View File

@@ -23,18 +23,20 @@ defmodule BDS.AI.ChatMessage do
def changeset(message, attrs) do
message
|> cast(attrs, [
:conversation_id,
:role,
:content,
:tool_call_id,
:tool_calls,
:token_usage_input,
:token_usage_output,
:cache_read_tokens,
:cache_write_tokens,
:created_at
], empty_values: [nil])
|> cast(
attrs,
[
:conversation_id,
:role,
:content,
:tool_call_id,
:tool_calls,
:token_usage_input,
:token_usage_output,
:cache_read_tokens,
:cache_write_tokens,
:created_at
], empty_values: [nil])
|> validate_required([:conversation_id, :role, :created_at])
|> assoc_constraint(:conversation)
end

View File

@@ -14,8 +14,10 @@ defmodule BDS.AI.ChatTools do
project_id = project_id || active_project_id()
%{
post_count: Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id),
media_count: Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id),
post_count:
Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id),
media_count:
Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id),
tag_count: Chat.count_distinct_string_list(Post, :tags, project_id),
category_count: Chat.count_distinct_string_list(Post, :categories, project_id)
}
@@ -132,9 +134,28 @@ defmodule BDS.AI.ChatTools do
project_tools =
if is_binary(project_id) do
[
%{name: "blog_stats", spec: tool_spec("blog_stats", "Return aggregate blog statistics", %{"type" => "object", "properties" => %{}})},
%{name: "list_posts", spec: tool_spec("list_posts", "List recent posts in the active project", limit_schema())},
%{name: "list_media", spec: tool_spec("list_media", "List recent media items in the active project", limit_schema())}
%{
name: "blog_stats",
spec:
tool_spec("blog_stats", "Return aggregate blog statistics", %{
"type" => "object",
"properties" => %{}
})
},
%{
name: "list_posts",
spec:
tool_spec("list_posts", "List recent posts in the active project", limit_schema())
},
%{
name: "list_media",
spec:
tool_spec(
"list_media",
"List recent media items in the active project",
limit_schema()
)
}
]
else
[]
@@ -142,14 +163,62 @@ defmodule BDS.AI.ChatTools do
project_tools ++
[
%{name: "render_card", spec: tool_spec("render_card", "Return a structured card payload", render_card_schema())},
%{name: "render_table", spec: tool_spec("render_table", "Return a structured table payload", render_table_schema())},
%{name: "render_chart", spec: tool_spec("render_chart", "Return a structured chart payload", render_chart_schema())},
%{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())},
%{name: "render_metric", spec: tool_spec("render_metric", "Return a structured metric payload", render_metric_schema())},
%{name: "render_list", spec: tool_spec("render_list", "Return a structured list payload", render_list_schema())},
%{name: "render_tabs", spec: tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())},
%{name: "render_mindmap", spec: tool_spec("render_mindmap", "Return a structured mindmap payload", render_mindmap_schema())}
%{
name: "render_card",
spec:
tool_spec("render_card", "Return a structured card payload", render_card_schema())
},
%{
name: "render_table",
spec:
tool_spec(
"render_table",
"Return a structured table payload",
render_table_schema()
)
},
%{
name: "render_chart",
spec:
tool_spec(
"render_chart",
"Return a structured chart payload",
render_chart_schema()
)
},
%{
name: "render_form",
spec:
tool_spec("render_form", "Return a structured form payload", render_form_schema())
},
%{
name: "render_metric",
spec:
tool_spec(
"render_metric",
"Return a structured metric payload",
render_metric_schema()
)
},
%{
name: "render_list",
spec:
tool_spec("render_list", "Return a structured list payload", render_list_schema())
},
%{
name: "render_tabs",
spec:
tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())
},
%{
name: "render_mindmap",
spec:
tool_spec(
"render_mindmap",
"Return a structured mindmap payload",
render_mindmap_schema()
)
}
]
else
[]

View File

@@ -2,7 +2,11 @@ defmodule BDS.AI.HttpClient do
@moduledoc false
def get(url, headers) when is_binary(url) and is_map(headers) do
request = {String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end)}
request =
{String.to_charlist(url),
Enum.map(headers, fn {key, value} ->
{String.to_charlist(key), String.to_charlist(value)}
end)}
:inets.start()
:ssl.start()
@@ -24,7 +28,10 @@ defmodule BDS.AI.HttpClient do
def post(url, headers, body)
when is_binary(url) and is_map(headers) and is_binary(body) do
request =
{String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end), ~c"application/json", body}
{String.to_charlist(url),
Enum.map(headers, fn {key, value} ->
{String.to_charlist(key), String.to_charlist(value)}
end), ~c"application/json", body}
:inets.start()
:ssl.start()

View File

@@ -34,31 +34,41 @@ defmodule BDS.AI.Model do
def changeset(model, attrs) do
model
|> cast(attrs, [
|> cast(
attrs,
[
:provider,
:model_id,
:name,
:family,
:supports_attachment,
:supports_reasoning,
:supports_tool_calls,
:supports_structured_output,
:supports_temperature,
:knowledge,
:release_date,
:last_updated_date,
:open_weights,
:input_price,
:output_price,
:cache_read_price,
:cache_write_price,
:context_window,
:max_input_tokens,
:max_output_tokens,
:interleaved,
:status,
:updated_at
], empty_values: [nil])
|> validate_required([
:provider,
:model_id,
:name,
:family,
:supports_attachment,
:supports_reasoning,
:supports_tool_calls,
:supports_structured_output,
:supports_temperature,
:knowledge,
:release_date,
:last_updated_date,
:open_weights,
:input_price,
:output_price,
:cache_read_price,
:cache_write_price,
:context_window,
:max_input_tokens,
:max_output_tokens,
:interleaved,
:status,
:updated_at
], empty_values: [nil])
|> validate_required([:provider, :model_id, :name, :context_window, :max_input_tokens, :max_output_tokens, :updated_at])
])
end
end

View File

@@ -30,12 +30,13 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
}
|> maybe_put_auth(endpoint.api_key)
payload = %{
"model" => request.model,
"messages" => request.messages,
"max_tokens" => request.max_output_tokens
}
|> maybe_put_tools(request.tools)
payload =
%{
"model" => request.model,
"messages" => request.messages,
"max_tokens" => request.max_output_tokens
}
|> maybe_put_tools(request.tools)
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
200 <- response.status do
@@ -55,7 +56,9 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
json =
case content do
nil -> nil
nil ->
nil
value when is_binary(value) ->
case Jason.decode(value) do
{:ok, decoded} when is_map(decoded) -> decoded
@@ -77,10 +80,17 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
defp models_url(url) do
cond do
String.ends_with?(url, "/chat/completions") -> String.replace_suffix(url, "/chat/completions", "/models")
String.ends_with?(url, "/models") -> url
String.ends_with?(url, "/") -> url <> "models"
true -> url <> "/models"
String.ends_with?(url, "/chat/completions") ->
String.replace_suffix(url, "/chat/completions", "/models")
String.ends_with?(url, "/models") ->
url
String.ends_with?(url, "/") ->
url <> "models"
true ->
url <> "/models"
end
end
@@ -114,7 +124,9 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
defp maybe_put_auth(headers, nil), do: headers
defp maybe_put_auth(headers, ""), do: headers
defp maybe_put_auth(headers, api_key), do: Map.put(headers, "authorization", "Bearer #{api_key}")
defp maybe_put_auth(headers, api_key),
do: Map.put(headers, "authorization", "Bearer #{api_key}")
defp maybe_put_tools(payload, []), do: payload
defp maybe_put_tools(payload, nil), do: payload

View File

@@ -65,7 +65,9 @@ defmodule BDS.AI.Runtime do
end
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do
{:ok, Keyword.get(extra, :model) || model_preference_value(:airplane_image_analysis) || endpoint.model}
{:ok,
Keyword.get(extra, :model) || model_preference_value(:airplane_image_analysis) ||
endpoint.model}
end
defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do
@@ -83,7 +85,8 @@ defmodule BDS.AI.Runtime do
defp fetch_endpoint_for_mode(mode, secret_backend) do
with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) do
case endpoint do
%{url: url, model: model} = loaded when is_binary(url) and url != "" and is_binary(model) and model != "" ->
%{url: url, model: model} = loaded
when is_binary(url) and url != "" and is_binary(model) and model != "" ->
if mode == :online and blank?(loaded.api_key) do
{:error, %{kind: :endpoint_not_configured, endpoint: mode}}
else

View File

@@ -17,7 +17,15 @@ defmodule BDS.AI.SecretBackend do
with {:ok, binary} <- Base.decode64(encoded),
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
plaintext when is_binary(plaintext) <-
:crypto.crypto_one_time_aead(:aes_256_gcm, secret_key(), iv, ciphertext, @aad, tag, false) do
:crypto.crypto_one_time_aead(
:aes_256_gcm,
secret_key(),
iv,
ciphertext,
@aad,
tag,
false
) do
{:ok, plaintext}
else
_other -> {:error, :invalid_ciphertext}

View File

@@ -21,8 +21,8 @@ defmodule BDS.AI.SettingsStore do
def put_setting(key, value) when is_binary(key) and is_binary(value) do
now = Persistence.now_ms()
(%Setting{}
|> Setting.changeset(%{key: key, value: value, updated_at: now}))
%Setting{}
|> Setting.changeset(%{key: key, value: value, updated_at: now})
|> Repo.insert(
on_conflict: [set: [value: value, updated_at: now]],
conflict_target: [:key]

View File

@@ -39,12 +39,18 @@ defmodule BDS.CliSync do
ids = Enum.map(notifications, & &1.id)
if ids != [] do
Repo.update_all(from(notification in Notification, where: notification.id in ^ids), set: [seen_at: now])
Repo.update_all(from(notification in Notification, where: notification.id in ^ids),
set: [seen_at: now]
)
end
{:ok,
Enum.map(notifications, fn notification ->
%{entity_type: notification.entity_type, entity_id: notification.entity_id, action: notification.action}
%{
entity_type: notification.entity_type,
entity_id: notification.entity_id,
action: notification.action
}
end)}
end
@@ -52,13 +58,17 @@ defmodule BDS.CliSync do
{processed_count, _} =
Repo.delete_all(
from notification in Notification,
where: not is_nil(notification.seen_at) and notification.created_at <= ^(now - @processed_ttl_ms)
where:
not is_nil(notification.seen_at) and
notification.created_at <= ^(now - @processed_ttl_ms)
)
{unprocessed_count, _} =
Repo.delete_all(
from notification in Notification,
where: is_nil(notification.seen_at) and notification.created_at <= ^(now - @unprocessed_ttl_ms)
where:
is_nil(notification.seen_at) and
notification.created_at <= ^(now - @unprocessed_ttl_ms)
)
{:ok, %{processed: processed_count, unprocessed: unprocessed_count}}

View File

@@ -15,7 +15,9 @@ defmodule BDS.CliSync.Notification do
def changeset(notification, attrs) do
notification
|> cast(attrs, [:entity_type, :entity_id, :action, :from_cli, :seen_at, :created_at], empty_values: [nil])
|> cast(attrs, [:entity_type, :entity_id, :action, :from_cli, :seen_at, :created_at],
empty_values: [nil]
)
|> validate_required([:entity_type, :entity_id, :action, :from_cli, :created_at])
end
end

View File

@@ -24,7 +24,11 @@ defmodule BDS.CliSync.Watcher do
@impl true
def init(opts) do
state = %{
poll_interval_ms: normalize_positive_integer(Keyword.get(opts, :poll_interval_ms), @default_poll_interval_ms),
poll_interval_ms:
normalize_positive_integer(
Keyword.get(opts, :poll_interval_ms),
@default_poll_interval_ms
),
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub)
}
@@ -49,7 +53,11 @@ defmodule BDS.CliSync.Watcher do
{:ok, _pruned} = CliSync.prune_notifications()
Enum.each(notifications, fn notification ->
Phoenix.PubSub.broadcast(state.pubsub, topic(), {:entity_changed, notification_payload(notification)})
Phoenix.PubSub.broadcast(
state.pubsub,
topic(),
{:entity_changed, notification_payload(notification)}
)
end)
state

View File

@@ -107,7 +107,9 @@ defmodule BDS.Desktop.Automation do
end
def handle_call({:native_menu_action, action}, _from, state) do
{reply, state} = driver_request(state, %{"command" => "native_menu_action", "action" => action})
{reply, state} =
driver_request(state, %{"command" => "native_menu_action", "action" => action})
{:reply, normalize_simple_reply(reply), state}
end
@@ -204,7 +206,9 @@ defmodule BDS.Desktop.Automation do
receive_driver_message(state, @request_timeout, fn message ->
case message do
%{"ref" => ^ref, "status" => "ok", "result" => result} -> {:ok, result}
%{"ref" => ^ref, "status" => "ok", "result" => result} ->
{:ok, result}
%{"ref" => ^ref, "status" => "error", "message" => reason} ->
raise "desktop automation request failed: #{reason}"
@@ -242,7 +246,8 @@ defmodule BDS.Desktop.Automation do
defp process_driver_messages(state, deadline, matcher) do
{messages, buffer} = split_driver_buffer(state.driver_buffer)
case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, {acc, _} ->
case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message,
{acc, _} ->
case decode_driver_message(message) do
:skip ->
{:cont, {acc, nil}}
@@ -259,7 +264,11 @@ defmodule BDS.Desktop.Automation do
receive do
{port, {:data, data}} when port == state.driver_port ->
process_driver_messages(%{state | driver_buffer: state.driver_buffer <> data}, deadline, matcher)
process_driver_messages(
%{state | driver_buffer: state.driver_buffer <> data},
deadline,
matcher
)
{port, {:exit_status, status}} when port == state.driver_port ->
raise "desktop automation driver exited with status #{status}"
@@ -311,7 +320,9 @@ defmodule BDS.Desktop.Automation do
defp do_wait_for_server(base_url, deadline) do
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
{:ok, {{_, 200, _}, _headers, _body}} -> :ok
{:ok, {{_, 200, _}, _headers, _body}} ->
:ok
_other ->
if System.monotonic_time(:millisecond) >= deadline do
raise "desktop app process did not become healthy in time"

View File

@@ -9,28 +9,30 @@ defmodule BDS.Desktop.Endpoint do
signing_salt: "desktop-shell"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]]
socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
plug Plug.Session, @session_options
plug :maybe_require_desktop_auth
plug(Plug.Session, @session_options)
plug(:maybe_require_desktop_auth)
plug Plug.Static,
plug(Plug.Static,
at: "/assets",
from: {:bds, "priv/ui"},
only: ["app.css", "live.js", "monaco"]
)
plug Plug.Static,
plug(Plug.Static,
at: "/vendor/phoenix",
from: {:phoenix, "priv/static"},
only: ["phoenix.min.js"]
)
plug Plug.Static,
plug(Plug.Static,
at: "/vendor/live_view",
from: {:phoenix_live_view, "priv/static"},
only: ["phoenix_live_view.min.js"]
)
plug BDS.Desktop.Router
plug(BDS.Desktop.Router)
defp maybe_require_desktop_auth(conn, _opts) do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do

View File

@@ -22,7 +22,9 @@ defmodule BDS.Desktop.MainWindow do
restored = restore_bounds()
{default_width, default_height} = Keyword.get(desktop_config, :window_size, @default_size)
{min_width, min_height} = Keyword.get(desktop_config, :window_min_size, @default_min_size)
startup_bounds = clamp_startup_bounds(restored || %{width: default_width, height: default_height})
startup_bounds =
clamp_startup_bounds(restored || %{width: default_width, height: default_height})
base_opts = [
app: :bds,
@@ -70,7 +72,9 @@ defmodule BDS.Desktop.MainWindow do
frame ->
apply_restored_bounds(frame)
schedule_persist()
{:noreply, %{state | frame: frame, last_bounds: current_bounds(frame) || state.last_bounds}}
{:noreply,
%{state | frame: frame, last_bounds: current_bounds(frame) || state.last_bounds}}
end
end
@@ -124,9 +128,15 @@ defmodule BDS.Desktop.MainWindow do
defp current_bounds(frame) do
with_wx_env(fn ->
cond do
not :wxWindow.isShown(frame) -> nil
:wxTopLevelWindow.isFullScreen(frame) -> nil
:wxTopLevelWindow.isMaximized(frame) -> nil
not :wxWindow.isShown(frame) ->
nil
:wxTopLevelWindow.isFullScreen(frame) ->
nil
:wxTopLevelWindow.isMaximized(frame) ->
nil
true ->
{x, y} = :wxWindow.getPosition(frame)
{width, height} = :wxWindow.getSize(frame)
@@ -160,7 +170,8 @@ defmodule BDS.Desktop.MainWindow do
end
defp normalize_bounds(%{x: x, y: y, width: width, height: height})
when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) and width > 0 and height > 0 do
when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) and
width > 0 and height > 0 do
{:ok, %{x: x, y: y, width: width, height: height}}
end
@@ -180,7 +191,8 @@ defmodule BDS.Desktop.MainWindow do
desktop_config = Application.get_env(:bds, :desktop, [])
case Keyword.get(desktop_config, :window_client_area_override) do
{x, y, width, height} when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) ->
{x, y, width, height}
when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) ->
%{x: x, y: y, width: width, height: height}
_ ->

View File

@@ -24,7 +24,8 @@ defmodule BDS.Desktop.MediaController do
with %{} = project <- Projects.get_active_project(),
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
true <- media.project_id == project.id,
relative_path when is_binary(relative_path) <- Media.thumbnail_paths(media)[thumbnail_size(size)],
relative_path when is_binary(relative_path) <-
Media.thumbnail_paths(media)[thumbnail_size(size)],
absolute_path = Path.join(Projects.project_data_dir(project), relative_path),
true <- File.exists?(absolute_path) do
{:ok, thumbnail_content_type(relative_path), absolute_path}
@@ -33,7 +34,8 @@ defmodule BDS.Desktop.MediaController do
end
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end

View File

@@ -168,14 +168,16 @@ defmodule BDS.Desktop.Overlay do
def close_lightbox(%{kind: :gallery} = overlay), do: %{overlay | lightbox: nil}
def close_lightbox(overlay), do: overlay
def lightbox_next(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) when is_map(lightbox) and images != [] do
def lightbox_next(%{kind: :gallery, lightbox: lightbox, images: images} = overlay)
when is_map(lightbox) and images != [] do
next_index = rem(lightbox.current_index + 1, length(images))
%{overlay | lightbox: lightbox_from_index(images, next_index)}
end
def lightbox_next(overlay), do: overlay
def lightbox_previous(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) when is_map(lightbox) and images != [] do
def lightbox_previous(%{kind: :gallery, lightbox: lightbox, images: images} = overlay)
when is_map(lightbox) and images != [] do
next_index = rem(lightbox.current_index - 1 + length(images), length(images))
%{overlay | lightbox: lightbox_from_index(images, next_index)}
end

View File

@@ -6,23 +6,23 @@ defmodule BDS.Desktop.Router do
import Phoenix.LiveView.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {BDS.Desktop.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_live_flash)
plug(:put_root_layout, html: {BDS.Desktop.Layouts, :root})
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
end
scope "/", BDS.Desktop do
pipe_through :browser
pipe_through(:browser)
get "/health", HealthController, :show
get "/media-thumbnail/:media_id", MediaController, :thumbnail
get("/health", HealthController, :show)
get("/media-thumbnail/:media_id", MediaController, :thumbnail)
live_session :desktop_shell,
root_layout: {BDS.Desktop.Layouts, :root} do
live "/", ShellLive, :index
live("/", ShellLive, :index)
end
end
end

View File

@@ -38,7 +38,8 @@ defmodule BDS.Desktop.ShellData do
Projects.shell_snapshot()
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table: projects") do
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table: projects") do
reraise error, __STACKTRACE__
end
@@ -54,7 +55,8 @@ defmodule BDS.Desktop.ShellData do
Dashboard.snapshot(project_id)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
@@ -65,7 +67,8 @@ defmodule BDS.Desktop.ShellData do
Sidebar.view(project_id, view_id, params)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
@@ -75,7 +78,10 @@ defmodule BDS.Desktop.ShellData do
def assistant_cards do
[
%{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."},
%{label: "Filesystem Sync", text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."},
%{
label: "Filesystem Sync",
text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."
},
%{label: "Desktop Runtime", text: "The app window is now served from LiveView state."}
]
end
@@ -117,7 +123,8 @@ defmodule BDS.Desktop.ShellData do
end
rescue
error in [DBConnection.OwnershipError, Exqlite.Error] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
@@ -146,17 +153,38 @@ defmodule BDS.Desktop.ShellData do
def activity_icon(id) do
case to_string(id) do
"posts" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"></path><path d="M8 12h8v2H8zm0 4h8v2H8z"></path></svg>)
"pages" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z"></path></svg>)
"media" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg>)
"scripts" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"></path></svg>)
"templates" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"></path></svg>)
"tags" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"></path></svg>)
"chat" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"></path><circle cx="8" cy="10" r="1.5"></circle><circle cx="12" cy="10" r="1.5"></circle><circle cx="16" cy="10" r="1.5"></circle></svg>)
"import" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path></svg>)
"git" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M22 11.73L12.27 2a1 1 0 0 0-1.41 0L8.84 4.02l2.56 2.56a1.2 1.2 0 0 1 1.52 1.53l2.47 2.47a1.2 1.2 0 1 1-.72.67l-2.3-2.3v6.06a1.2 1.2 0 1 1-.85 0V8.9a1.2 1.2 0 0 1-.66-1.59L8.35 4.8 2 11.16a1 1 0 0 0 0 1.41L11.73 22a1 1 0 0 0 1.41 0L22 13.14a1 1 0 0 0 0-1.41z"></path></svg>)
"settings" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg>)
_other -> activity_icon("posts")
"posts" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"></path><path d="M8 12h8v2H8zm0 4h8v2H8z"></path></svg>)
"pages" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z"></path></svg>)
"media" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg>)
"scripts" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"></path></svg>)
"templates" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"></path></svg>)
"tags" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"></path></svg>)
"chat" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"></path><circle cx="8" cy="10" r="1.5"></circle><circle cx="12" cy="10" r="1.5"></circle><circle cx="16" cy="10" r="1.5"></circle></svg>)
"import" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path></svg>)
"git" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M22 11.73L12.27 2a1 1 0 0 0-1.41 0L8.84 4.02l2.56 2.56a1.2 1.2 0 0 1 1.52 1.53l2.47 2.47a1.2 1.2 0 1 1-.72.67l-2.3-2.3v6.06a1.2 1.2 0 1 1-.85 0V8.9a1.2 1.2 0 0 1-.66-1.59L8.35 4.8 2 11.16a1 1 0 0 0 0 1.41L11.73 22a1 1 0 0 0 1.41 0L22 13.14a1 1 0 0 0 0-1.41z"></path></svg>)
"settings" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg>)
_other ->
activity_icon("posts")
end
end
@@ -171,7 +199,10 @@ defmodule BDS.Desktop.ShellData do
def dashboard_post_count_label(count) do
normalized_count = count || 0
key = if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other"
key =
if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other"
translate(key, %{count: normalized_count})
end
@@ -188,7 +219,7 @@ defmodule BDS.Desktop.ShellData do
top_items
|> Enum.map(fn item ->
font_size = 11 + (((item.count || 0) - min_count) / range) * 11
font_size = 11 + ((item.count || 0) - min_count) / range * 11
Map.merge(item, %{font_size: font_size, color: normalize_dashboard_tag_color(item.color)})
end)
|> Enum.sort_by(&String.downcase(to_string(&1.tag || "")))
@@ -199,10 +230,11 @@ defmodule BDS.Desktop.ShellData do
declarations =
if item.color do
declarations ++ [
"background-color: #{item.color};",
"color: #{dashboard_contrast_color(item.color)};"
]
declarations ++
[
"background-color: #{item.color};",
"color: #{dashboard_contrast_color(item.color)};"
]
else
declarations
end
@@ -225,9 +257,17 @@ defmodule BDS.Desktop.ShellData do
def route_label(route) do
case to_string(route) do
"git_log" -> "Git Log"
"post_links" -> "Post Links"
other -> other |> String.replace("_", " ") |> String.split() |> Enum.map_join(" ", &String.capitalize/1)
"git_log" ->
"Git Log"
"post_links" ->
"Post Links"
other ->
other
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
end
@@ -255,7 +295,10 @@ defmodule BDS.Desktop.ShellData do
defp effective_ui_language(locale), do: locale
defp maybe_add_panel_tab(tabs, :post, :post_links), do: tabs ++ [:post_links]
defp maybe_add_panel_tab(tabs, route, :git_log) when route in [:post, :media], do: tabs ++ [:git_log]
defp maybe_add_panel_tab(tabs, route, :git_log) when route in [:post, :media],
do: tabs ++ [:git_log]
defp maybe_add_panel_tab(tabs, _route, _tab), do: tabs
defp default_project_snapshot do

View File

@@ -8,10 +8,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
embed_templates "chat_editor_html/*"
embed_templates("chat_editor_html/*")
# ── Public API: state assignment ───────────────────────────────────────────
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assign(socket, :chat_editor, MessageBuild.build(socket.assigns))
end
@@ -25,6 +26,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Public API: input + surface state ──────────────────────────────────────
@spec update_input(term(), term(), term()) :: term()
def update_input(socket, value, reload) do
%{id: conversation_id} = socket.assigns.current_tab
@@ -36,6 +38,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench)
end
@spec update_surface_form(term(), term(), term(), term()) :: term()
def update_surface_form(socket, surface_id, fields, reload)
when is_binary(surface_id) and is_map(fields) do
next_data = Map.put(socket.assigns.chat_editor_surface_data, surface_id, fields)
@@ -45,6 +48,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench)
end
@spec select_surface_tab(term(), term(), term(), term()) :: term()
def select_surface_tab(socket, surface_id, index, reload)
when is_binary(surface_id) and is_integer(index) and index >= 0 do
socket
@@ -55,10 +59,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench)
end
@spec current_surface_data(term(), term()) :: term()
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
end
@spec set_action_error(term(), term(), term(), term()) :: term()
def set_action_error(socket, conversation_id, message, reload)
when is_binary(conversation_id) and is_binary(message) do
socket
@@ -69,6 +75,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench)
end
@spec clear_action_error(term(), term(), term()) :: term()
def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do
socket
|> assign(
@@ -80,6 +87,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Public API: messaging ──────────────────────────────────────────────────
@spec send_message(term(), term(), term()) :: term()
def send_message(socket, reload, append_output) do
%{id: conversation_id} = socket.assigns.current_tab
message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim()
@@ -144,6 +152,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end
end
@spec abort_message(term(), term()) :: term()
def abort_message(socket, reload) do
%{id: conversation_id} = socket.assigns.current_tab
@@ -167,6 +176,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end
end
@spec note_tool_call(term(), term(), term(), term()) :: term()
def note_tool_call(socket, conversation_id, tool_call, reload)
when is_binary(conversation_id) and is_map(tool_call) do
update_request(
@@ -189,6 +199,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
)
end
@spec note_tool_result(term(), term(), term(), term()) :: term()
def note_tool_result(socket, conversation_id, name, reload)
when is_binary(conversation_id) and is_binary(name) do
update_request(
@@ -201,6 +212,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
)
end
@spec note_streaming_content(term(), term(), term(), term()) :: term()
def note_streaming_content(socket, conversation_id, content, reload)
when is_binary(conversation_id) and is_binary(content) do
update_request(
@@ -211,6 +223,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
)
end
@spec finish_request(term(), term(), term(), term(), term()) :: term()
def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do
case Map.pop(socket.assigns.chat_editor_request_refs, ref) do
{nil, _remaining_refs} ->
@@ -245,12 +258,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── HEEx-callable helpers ─────────────────────────────────────────────────
@spec message_role_label(term()) :: term()
def message_role_label(:user), do: translated("chat.role.you")
def message_role_label(_role), do: translated("chat.role.assistant")
defdelegate tool_call_name(tool_call), to: ToolTracking
defdelegate tool_call_arguments(tool_call), to: ToolTracking
@spec tool_surface_type(term()) :: term()
def tool_surface_type(surface), do: Map.get(surface, :type, "json")
def markdown_html(content) when is_binary(content) do
@@ -264,8 +279,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
raw(html)
end
@spec markdown_html(term()) :: term()
def markdown_html(_content), do: ""
@spec payload_json(term()) :: term()
def payload_json(nil), do: "{}"
def payload_json(payload) when is_map(payload), do: Jason.encode!(payload)
@@ -280,15 +297,18 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> Float.round(2)
end
@spec chart_width(term(), term()) :: term()
def chart_width(_max_value, _value), do: 0
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false
# ── HEEx components ───────────────────────────────────────────────────────
attr :markers, :list, required: true
attr(:markers, :list, required: true)
@spec chat_tool_markers(term()) :: term()
def chat_tool_markers(assigns) do
~H"""
<%= if @markers != [] do %>
@@ -307,8 +327,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
"""
end
attr :surface, :map, required: true
attr(:surface, :map, required: true)
@spec chat_surface(term()) :: term()
def chat_surface(assigns) do
~H"""
<article class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface">
@@ -548,7 +569,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
fn _match, src, alt -> external_image_link(src, alt) end
)
Regex.replace(~r/<img\b(?=[^>]*\bsrc="(https?:\/\/[^\"]+)")[^>]*\/?>/i, html, fn _match, src ->
Regex.replace(~r/<img\b(?=[^>]*\bsrc="(https?:\/\/[^\"]+)")[^>]*\/?>/i, html, fn _match,
src ->
external_image_link(src, src)
end)
end
@@ -571,6 +593,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp format_error(reason), do: inspect(reason)
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{ModelSelection, ToolSurfaces, ToolTracking}
@spec build(term()) :: term()
def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do
case AI.get_chat_conversation(conversation_id) do
nil ->

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
import Phoenix.Component, only: [assign: 3]
@spec toggle_model_selector(term(), term()) :: term()
def toggle_model_selector(socket, reload) do
%{id: conversation_id} = socket.assigns.current_tab
current = Map.get(socket.assigns.chat_model_selectors_open, conversation_id, false)
@@ -18,6 +19,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
|> reload.(socket.assigns.workbench)
end
@spec set_model(term(), term(), term(), term()) :: term()
def set_model(socket, model_id, reload, append_output) do
%{id: conversation_id} = socket.assigns.current_tab
@@ -37,6 +39,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
end
end
@spec group_available_models(term()) :: term()
def group_available_models(models) when is_list(models) do
models
|> Enum.group_by(&Map.get(&1, :provider, "other"))
@@ -54,6 +57,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
|> Enum.sort_by(&String.downcase(to_string(&1.label)))
end
@spec needs_api_key?(term()) :: term()
def needs_api_key?(true), do: false
def needs_api_key?(false) do

View File

@@ -14,9 +14,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
"render_tabs"
])
@spec render_tool?(term()) :: term()
def render_tool?(name) when is_binary(name), do: MapSet.member?(@render_tool_names, name)
@spec render_tool?(term()) :: term()
def render_tool?(_name), do: false
@spec build_render_surfaces(term(), term(), term()) :: term()
def build_render_surfaces(tool_calls, message_id, assigns) do
tool_calls
|> Enum.with_index()
@@ -28,6 +31,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
end)
end
@spec build_render_surface(term(), term(), term()) :: term()
def build_render_surface(%{name: name, arguments: arguments}, surface_id, assigns) do
if MapSet.member?(@render_tool_names, name) do
do_build_render_surface(name, arguments || %{}, surface_id, assigns)
@@ -51,6 +55,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
end
end
@spec normalize_tool_surface(term()) :: term()
def normalize_tool_surface(_content), do: nil
defp do_build_render_surface("render_card", arguments, surface_id, _assigns) do
@@ -150,7 +155,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
label: map_value(field, "label", key),
input_type: map_value(field, "inputType") || map_value(field, "input_type", "text"),
placeholder: map_value(field, "placeholder"),
value: Map.get(stored_fields, key, map_value(field, "defaultValue") || map_value(field, "default_value")),
value:
Map.get(
stored_fields,
key,
map_value(field, "defaultValue") || map_value(field, "default_value")
),
options: decode_surface_options(map_value(field, "options", [])),
required?: truthy?(map_value(field, "required", false))
}
@@ -161,8 +171,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
type: "form",
title: map_value(arguments, "title"),
fields: fields,
submit_label: map_value(arguments, "submitLabel") || map_value(arguments, "submit_label", translated("chat.stop")),
submit_action: map_value(arguments, "submitAction") || map_value(arguments, "submit_action", "submitForm")
submit_label:
map_value(arguments, "submitLabel") ||
map_value(arguments, "submit_label", translated("chat.stop")),
submit_action:
map_value(arguments, "submitAction") ||
map_value(arguments, "submit_action", "submitForm")
}
end
@@ -181,7 +195,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|> List.wrap()
|> Enum.with_index()
|> Enum.map(fn {content, content_index} ->
build_tab_surface(content, "#{surface_id}-tab-#{tab_index}-#{content_index}", assigns)
build_tab_surface(
content,
"#{surface_id}-tab-#{tab_index}-#{content_index}",
assigns
)
end)
}
end)
@@ -203,11 +221,21 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
type = map_value(content, "type", "text")
case type do
render_type when render_type in ["card", "chart", "form", "list", "metric", "mindmap", "table", "tabs"] ->
do_build_render_surface("render_#{render_type}", Map.delete(content, "type"), surface_id, assigns)
render_type
when render_type in ["card", "chart", "form", "list", "metric", "mindmap", "table", "tabs"] ->
do_build_render_surface(
"render_#{render_type}",
Map.delete(content, "type"),
surface_id,
assigns
)
"text" ->
%{id: surface_id, type: "text", body: map_value(content, "body") || map_value(content, "text", "")}
%{
id: surface_id,
type: "text",
body: map_value(content, "body") || map_value(content, "text", "")
}
_other ->
%{id: surface_id, type: "json", raw: content}

View File

@@ -3,10 +3,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
@tool_args_max_length 30
@spec tool_call_name(term()) :: term()
def tool_call_name(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :name) || "tool"
end
@spec tool_call_arguments(term()) :: term()
def tool_call_arguments(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
end
@@ -25,6 +27,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
end)
end
@spec normalize_tool_calls(term()) :: term()
def normalize_tool_calls(_tool_calls), do: []
def tool_arguments_preview(arguments) when is_map(arguments) do
@@ -33,6 +36,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|> Enum.join(", ")
end
@spec tool_arguments_preview(term()) :: term()
def tool_arguments_preview(_arguments), do: ""
def mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do
@@ -47,8 +51,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
end)
end
@spec mark_tool_call_completed(term(), term()) :: term()
def mark_tool_call_completed(entry, _tool_call_id), do: entry
@spec tool_markers_from_events(term()) :: term()
def tool_markers_from_events(nil), do: []
def tool_markers_from_events(%{tool_events: tool_events}) do

View File

@@ -10,12 +10,14 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
embed_templates("code_entity_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
socket
|> assign(:script_editor, build_script(socket.assigns))
|> assign(:template_editor, build_template(socket.assigns))
end
@spec update_script(term(), term(), term()) :: term()
def update_script(socket, params, reload) do
%{id: script_id} = socket.assigns.current_tab
@@ -27,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
|> reload.(socket.assigns.workbench)
end
@spec save_script(term(), term(), term()) :: term()
def save_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
@@ -62,6 +65,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end
end
@spec check_script(term(), term(), term()) :: term()
def check_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
@@ -82,6 +86,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end
end
@spec run_script(term(), term(), term()) :: term()
def run_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
@@ -111,6 +116,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end
end
@spec delete_script(term(), term(), term()) :: term()
def delete_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
@@ -124,6 +130,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end
end
@spec update_template(term(), term(), term()) :: term()
def update_template(socket, params, reload) do
%{id: template_id} = socket.assigns.current_tab
@@ -139,6 +146,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
|> reload.(socket.assigns.workbench)
end
@spec save_template(term(), term(), term()) :: term()
def save_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab
@@ -169,6 +177,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end
end
@spec validate_template(term(), term(), term()) :: term()
def validate_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab
@@ -195,6 +204,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end
end
@spec delete_template(term(), term(), term()) :: term()
def delete_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab
@@ -211,6 +221,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end
end
@spec build_script(term()) :: term()
def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do
case Scripts.get_script(script_id) do
nil ->
@@ -236,6 +247,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
def build_script(_assigns), do: nil
@spec build_template(term()) :: term()
def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do
case Templates.get_template(template_id) do
nil ->
@@ -259,9 +271,11 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
def build_template(_assigns), do: nil
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec format_timestamp(term()) :: term()
def format_timestamp(nil), do: ""
def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp)

View File

@@ -57,7 +57,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
total,
detail,
reload
), to: ProgressTracking
),
to: ProgressTracking
defdelegate finish_execution(socket, ref, result, reload, append_output), to: ProgressTracking
@@ -72,6 +73,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
defdelegate clear_taxonomy_mapping(socket, params, reload), to: TaxonomyEditing
defdelegate analyze_taxonomy_ai(socket, reload, append_output), to: TaxonomyEditing
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
case socket.assigns[:current_tab] do
%{type: :import, id: definition_id} ->
@@ -140,6 +142,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
end
end
@spec toggle_section(term(), term(), term()) :: term()
def toggle_section(socket, section, reload) do
with %{id: definition_id} <- socket.assigns.current_tab,
section_key
@@ -171,6 +174,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
end
end
@spec toggle_model_selector(term(), term()) :: term()
def toggle_model_selector(socket, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do
current = Map.get(socket.assigns.import_editor_model_selectors_open, definition_id, false)
@@ -186,6 +190,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
end
end
@spec select_ai_model(term(), term(), term()) :: term()
def select_ai_model(socket, model_id, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do
socket
@@ -205,6 +210,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:import_editor, :map, required: true)
@spec import_editor(term()) :: term()
def import_editor(assigns) do
assigns =
assigns
@@ -547,6 +553,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:expanded, :boolean, required: true)
attr(:section, :string, required: true)
@spec conflict_section(term()) :: term()
def conflict_section(assigns) do
~H"""
<section class="import-detail-section conflicts-section">
@@ -597,6 +604,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:section, :string, required: true)
attr(:show_type, :boolean, default: false)
@spec post_detail_section(term()) :: term()
def post_detail_section(assigns) do
~H"""
<section class="import-detail-section">
@@ -646,6 +654,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:expanded, :boolean, required: true)
attr(:section, :string, required: true)
@spec media_detail_section(term()) :: term()
def media_detail_section(assigns) do
~H"""
<section class="import-detail-section">
@@ -685,6 +694,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:label, :string, required: true)
attr(:stats, :map, required: true)
@spec stat_card(term()) :: term()
def stat_card(assigns) do
~H"""
<div class="import-stat-card">
@@ -703,6 +713,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:label, :string, required: true)
attr(:stats, :map, required: true)
@spec other_stat_card(term()) :: term()
def other_stat_card(assigns) do
~H"""
<div class="import-stat-card import-stat-card-other">
@@ -720,6 +731,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:label, :string, required: true)
attr(:stats, :map, required: true)
@spec media_stat_card(term()) :: term()
def media_stat_card(assigns) do
~H"""
<div class="import-stat-card">
@@ -739,6 +751,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:label, :string, required: true)
attr(:stats, :map, required: true)
@spec taxonomy_stat_card(term()) :: term()
def taxonomy_stat_card(assigns) do
~H"""
<div class="import-stat-card">
@@ -759,6 +772,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:edit, :map, default: nil)
attr(:type, :string, required: true)
@spec taxonomy_group(term()) :: term()
def taxonomy_group(assigns) do
~H"""
<div class="taxonomy-group">

View File

@@ -4,20 +4,27 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
@spec change_definition(term(), term(), term()) :: term()
def change_definition(socket, params, reload) do
with %{id: definition_id} <- socket.assigns.current_tab,
{:ok, _definition} <- ImportDefinitions.update_definition(definition_id, %{name: Map.get(params, "name", "")}) do
{:ok, _definition} <-
ImportDefinitions.update_definition(definition_id, %{name: Map.get(params, "name", "")}) do
reload.(socket, socket.assigns.workbench)
else
_other -> reload.(socket, socket.assigns.workbench)
end
end
@spec select_uploads_folder(term(), term(), term()) :: term()
def select_uploads_folder(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab do
case FolderPicker.choose_directory(translated("importAnalysis.uploadsFolder")) do
{:ok, uploads_folder_path} ->
{:ok, _definition} = ImportDefinitions.update_definition(definition_id, %{uploads_folder_path: uploads_folder_path})
{:ok, _definition} =
ImportDefinitions.update_definition(definition_id, %{
uploads_folder_path: uploads_folder_path
})
reload.(socket, socket.assigns.workbench)
:cancel ->
@@ -33,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end
end
@spec select_and_analyze(term(), term(), term()) :: term()
def select_and_analyze(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id) do
@@ -50,9 +58,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
ImportAnalysis.analyze_wxr(project_id, wxr_file_path, definition.uploads_folder_path,
ImportAnalysis.analyze_wxr(
project_id,
wxr_file_path,
definition.uploads_folder_path,
on_progress: fn step, detail ->
send(live_view_pid, {:import_analysis_progress, definition_id, translate_phase(step), detail})
send(
live_view_pid,
{:import_analysis_progress, definition_id, translate_phase(step), detail}
)
end
)
end)
@@ -70,8 +84,14 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
ref: task.ref
})
)
|> Phoenix.Component.assign(:import_editor_analysis_task_refs, Map.put(socket.assigns.import_editor_analysis_task_refs, task.ref, definition_id))
|> Phoenix.Component.assign(:import_editor_execution_states, Map.delete(socket.assigns.import_editor_execution_states, definition_id))
|> Phoenix.Component.assign(
:import_editor_analysis_task_refs,
Map.put(socket.assigns.import_editor_analysis_task_refs, task.ref, definition_id)
)
|> Phoenix.Component.assign(
:import_editor_execution_states,
Map.delete(socket.assigns.import_editor_execution_states, definition_id)
)
|> reload.(socket.assigns.workbench)
:cancel ->
@@ -87,32 +107,50 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end
end
@spec note_analysis_progress(term(), term(), term(), term(), term()) :: term()
def note_analysis_progress(socket, definition_id, step, detail, reload) do
socket
|> Phoenix.Component.assign(
:import_editor_analysis_states,
Map.update(socket.assigns.import_editor_analysis_states, definition_id, default_analysis_state(), fn state ->
state
|> Map.put(:loading, true)
|> Map.put(:step, step)
|> Map.put(:detail, detail)
end)
Map.update(
socket.assigns.import_editor_analysis_states,
definition_id,
default_analysis_state(),
fn state ->
state
|> Map.put(:loading, true)
|> Map.put(:step, step)
|> Map.put(:detail, detail)
end
)
)
|> reload.(socket.assigns.workbench)
end
@spec finish_analysis(term(), term(), term(), term(), term()) :: term()
def finish_analysis(socket, ref, result, reload, append_output) do
case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do
nil ->
socket
definition_id ->
analysis_state = Map.get(socket.assigns.import_editor_analysis_states, definition_id, default_analysis_state())
analysis_state =
Map.get(
socket.assigns.import_editor_analysis_states,
definition_id,
default_analysis_state()
)
socket =
socket
|> Phoenix.Component.assign(:import_editor_analysis_task_refs, Map.delete(socket.assigns.import_editor_analysis_task_refs, ref))
|> Phoenix.Component.assign(:import_editor_analysis_states, Map.delete(socket.assigns.import_editor_analysis_states, definition_id))
|> Phoenix.Component.assign(
:import_editor_analysis_task_refs,
Map.delete(socket.assigns.import_editor_analysis_task_refs, ref)
)
|> Phoenix.Component.assign(
:import_editor_analysis_states,
Map.delete(socket.assigns.import_editor_analysis_states, definition_id)
)
case result do
{:ok, report} ->
@@ -146,6 +184,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end
end
@spec handle_analysis_task_down(term(), term(), term(), term(), term()) :: term()
def handle_analysis_task_down(socket, ref, message, reload, append_output) do
case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do
nil ->
@@ -153,13 +192,20 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
definition_id ->
socket
|> Phoenix.Component.assign(:import_editor_analysis_task_refs, Map.delete(socket.assigns.import_editor_analysis_task_refs, ref))
|> Phoenix.Component.assign(:import_editor_analysis_states, Map.delete(socket.assigns.import_editor_analysis_states, definition_id))
|> Phoenix.Component.assign(
:import_editor_analysis_task_refs,
Map.delete(socket.assigns.import_editor_analysis_task_refs, ref)
)
|> Phoenix.Component.assign(
:import_editor_analysis_states,
Map.delete(socket.assigns.import_editor_analysis_states, definition_id)
)
|> append_output.(translated("activity.import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@spec importable_counts(term()) :: term()
def importable_counts(nil), do: %{total: 0, tags: 0, posts: 0, media: 0, pages: 0}
def importable_counts(report) do
@@ -171,25 +217,37 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
pages = importable_entity_count(Map.get(report.items, :pages, []))
media = importable_entity_count(Map.get(report.items, :media, []))
%{total: tag_count + posts + pages + media, tags: tag_count, posts: posts, media: media, pages: pages}
%{
total: tag_count + posts + pages + media,
tags: tag_count,
posts: posts,
media: media,
pages: pages
}
end
@spec importable_entity_count(term()) :: term()
def importable_entity_count(items) do
Enum.count(items || [], fn item ->
item.status == "new" or (item.status == "conflict" and Map.get(item, :resolution, "ignore") not in ["ignore", "skip"])
item.status == "new" or
(item.status == "conflict" and
Map.get(item, :resolution, "ignore") not in ["ignore", "skip"])
end)
end
@spec detail_items(term(), term()) :: term()
def detail_items(nil, _bucket), do: []
def detail_items(report, bucket) do
get_in(report, [:details, bucket]) || get_in(report, [:items, bucket]) || []
end
@spec default_analysis_state() :: term()
def default_analysis_state do
%{loading: false, step: nil, detail: nil, file_path: nil, ref: nil}
end
@spec default_sections() :: term()
def default_sections do
%{
post_conflicts: true,
@@ -203,18 +261,22 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
}
end
@spec default_author(term()) :: term()
def default_author(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
Map.get(metadata, :default_author)
end
@spec suggested_definition_name(term()) :: term()
def suggested_definition_name(report) 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
try do
@@ -241,8 +303,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end
end
@spec translate_phase(term()) :: term()
def translate_phase(other), do: other
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp present?(value), do: value not in [nil, ""]
end

View File

@@ -3,18 +3,27 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ConflictResolution do
alias BDS.ImportDefinitions
def change_conflict_resolution(socket, %{"item_type" => item_type, "item_name" => item_name, "resolution" => resolution}, reload) do
@spec change_conflict_resolution(term(), term(), term()) :: term()
def change_conflict_resolution(
socket,
%{"item_type" => item_type, "item_name" => item_name, "resolution" => resolution},
reload
) do
with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id),
%{} = report <- ImportDefinitions.decode_analysis_result(definition),
updated_report <- update_conflict_resolution(report, item_type, item_name, resolution),
{:ok, _definition} <- ImportDefinitions.update_definition(definition_id, %{last_analysis_result: updated_report}) do
{:ok, _definition} <-
ImportDefinitions.update_definition(definition_id, %{
last_analysis_result: updated_report
}) do
reload.(socket, socket.assigns.workbench)
else
_other -> reload.(socket, socket.assigns.workbench)
end
end
@spec update_conflict_resolution(term(), term(), term(), term()) :: term()
def update_conflict_resolution(report, item_type, item_name, resolution) do
report
|> update_in([:conflicts], fn conflicts ->
@@ -30,10 +39,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ConflictResolution do
|> update_in([:details], &update_conflict_bucket(&1, item_type, item_name, resolution))
end
@spec update_conflict_bucket(term(), term(), term(), term()) :: term()
def update_conflict_bucket(nil, _item_type, _item_name, _resolution), do: nil
def update_conflict_bucket(buckets, item_type, item_name, resolution) do
bucket_key = if(item_type == "page", do: :pages, else: if(item_type == "media", do: :media, else: :posts))
bucket_key =
if(item_type == "page",
do: :pages,
else: if(item_type == "media", do: :media, else: :posts)
)
update_in(buckets, [bucket_key], fn items ->
Enum.map(items || [], fn item ->

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState
@spec execute_import(term(), term(), term()) :: term()
def execute_import(socket, reload, _append_output) do
with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id),
@@ -24,7 +25,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
uploads_folder_path: definition.uploads_folder_path,
default_author: default_author,
on_progress: fn phase, current, total, detail ->
send(live_view_pid, {:import_execution_progress, definition_id, phase, current, total, detail})
send(
live_view_pid,
{:import_execution_progress, definition_id, phase, current, total, detail}
)
end
)
end)
@@ -50,7 +54,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: task.ref
})
)
|> Phoenix.Component.assign(:import_editor_execution_task_refs, Map.put(socket.assigns.import_editor_execution_task_refs, task.ref, definition_id))
|> Phoenix.Component.assign(
:import_editor_execution_task_refs,
Map.put(socket.assigns.import_editor_execution_task_refs, task.ref, definition_id)
)
|> reload.(socket.assigns.workbench)
end
else
@@ -58,6 +65,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end
end
@spec note_execution_progress(term(), term(), term(), term(), term(), term(), term()) :: term()
def note_execution_progress(socket, definition_id, phase, current, total, detail, reload) do
{detail_text, eta} = decompose_progress_detail(detail)
translated_phase = translate_execution_phase(phase)
@@ -65,30 +73,44 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
socket
|> Phoenix.Component.assign(
:import_editor_execution_states,
Map.update(socket.assigns.import_editor_execution_states, definition_id, default_execution_state(), fn state ->
state
|> Map.put(:is_executing, true)
|> Map.put(:phase, translated_phase)
|> Map.put(:current, current)
|> Map.put(:total, total)
|> Map.put(:detail, detail_text)
|> Map.put(:eta, eta)
end)
Map.update(
socket.assigns.import_editor_execution_states,
definition_id,
default_execution_state(),
fn state ->
state
|> Map.put(:is_executing, true)
|> Map.put(:phase, translated_phase)
|> Map.put(:current, current)
|> Map.put(:total, total)
|> Map.put(:detail, detail_text)
|> Map.put(:eta, eta)
end
)
)
|> reload.(socket.assigns.workbench)
end
@spec finish_execution(term(), term(), term(), term(), term()) :: term()
def finish_execution(socket, ref, result, reload, append_output) do
case Map.get(socket.assigns.import_editor_execution_task_refs, ref) do
nil ->
socket
definition_id ->
previous_state = Map.get(socket.assigns.import_editor_execution_states, definition_id, default_execution_state())
previous_state =
Map.get(
socket.assigns.import_editor_execution_states,
definition_id,
default_execution_state()
)
socket =
socket
|> Phoenix.Component.assign(:import_editor_execution_task_refs, Map.delete(socket.assigns.import_editor_execution_task_refs, ref))
|> Phoenix.Component.assign(
:import_editor_execution_task_refs,
Map.delete(socket.assigns.import_editor_execution_task_refs, ref)
)
case result do
{:ok, execution_result} ->
@@ -106,7 +128,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: nil
})
)
|> append_output.(translated("activity.import"), translated("importAnalysis.importComplete", %{count: previous_state.count}), nil, "info")
|> append_output.(
translated("activity.import"),
translated("importAnalysis.importComplete", %{count: previous_state.count}),
nil,
"info"
)
|> reload.(socket.assigns.workbench)
{:error, %{message: message}} ->
@@ -144,7 +171,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end
end
def handle_task_down(socket, kind, ref, reason, reload, append_output) when reason not in [:normal, :shutdown] do
@spec handle_task_down(term(), term(), term(), term(), term(), term()) :: term()
def handle_task_down(socket, kind, ref, reason, reload, append_output)
when reason not in [:normal, :shutdown] do
message = inspect(reason)
case kind do
@@ -157,10 +186,18 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
socket
definition_id ->
previous_state = Map.get(socket.assigns.import_editor_execution_states, definition_id, default_execution_state())
previous_state =
Map.get(
socket.assigns.import_editor_execution_states,
definition_id,
default_execution_state()
)
socket
|> Phoenix.Component.assign(:import_editor_execution_task_refs, Map.delete(socket.assigns.import_editor_execution_task_refs, ref))
|> Phoenix.Component.assign(
:import_editor_execution_task_refs,
Map.delete(socket.assigns.import_editor_execution_task_refs, ref)
)
|> Phoenix.Component.assign(
:import_editor_execution_states,
Map.put(socket.assigns.import_editor_execution_states, definition_id, %{
@@ -177,8 +214,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end
end
@spec handle_task_down(term(), term(), term(), term(), term(), term()) :: term()
def handle_task_down(socket, _kind, _ref, _reason, _reload, _append_output), do: socket
@spec default_execution_state() :: term()
def default_execution_state do
%{
is_executing: false,
@@ -195,6 +234,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
}
end
@spec execution_progress_width(term()) :: term()
def execution_progress_width(state) do
current = Map.get(state, :current, 0)
total = Map.get(state, :total, 0)
@@ -205,25 +245,36 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end
end
@spec decompose_progress_detail(term()) :: term()
def decompose_progress_detail(%{detail: detail, eta: eta}), do: {to_string_or_nil(detail), eta}
def decompose_progress_detail(detail) when is_binary(detail) or is_nil(detail), do: {detail, nil}
def decompose_progress_detail(detail) when is_binary(detail) or is_nil(detail),
do: {detail, nil}
def decompose_progress_detail(detail), do: {to_string_or_nil(detail), nil}
@spec to_string_or_nil(term()) :: term()
def to_string_or_nil(nil), do: nil
def to_string_or_nil(value) when is_binary(value), do: value
def to_string_or_nil(value), do: inspect(value)
@spec format_eta(term()) :: term()
def format_eta(nil), do: nil
def format_eta(ms) when is_integer(ms) and ms >= 0 do
seconds = div(ms, 1000)
if seconds < 60 do
translated("importAnalysis.eta", %{value: translated("importAnalysis.etaSeconds", %{count: seconds})})
translated("importAnalysis.eta", %{
value: translated("importAnalysis.etaSeconds", %{count: seconds})
})
else
m = div(seconds, 60)
s = rem(seconds, 60)
translated("importAnalysis.eta", %{value: translated("importAnalysis.etaMinutes", %{minutes: m, seconds: s})})
translated("importAnalysis.eta", %{
value: translated("importAnalysis.etaMinutes", %{minutes: m, seconds: s})
})
end
end
@@ -240,7 +291,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end
end
@spec translate_execution_phase(term()) :: term()
def translate_execution_phase(other), do: other
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
alias BDS.{AI, ImportDefinitions, Metadata, Tags}
alias BDS.Desktop.ShellData
@spec start_taxonomy_edit(term(), term(), term()) :: term()
def start_taxonomy_edit(
socket,
%{"type" => type, "name" => name, "mapped_to" => mapped_to},
@@ -25,6 +26,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@spec cancel_taxonomy_edit(term(), term()) :: term()
def cancel_taxonomy_edit(socket, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do
socket
@@ -38,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@spec save_taxonomy_edit(term(), term(), term()) :: term()
def save_taxonomy_edit(
socket,
%{"type" => type, "name" => name, "mapped_to" => mapped_to},
@@ -68,10 +71,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@spec clear_taxonomy_mapping(term(), term(), term()) :: term()
def clear_taxonomy_mapping(socket, %{"type" => type, "name" => name}, reload) do
save_taxonomy_edit(socket, %{"type" => type, "name" => name, "mapped_to" => ""}, reload)
end
@spec analyze_taxonomy_ai(term(), term(), term()) :: term()
def analyze_taxonomy_ai(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id),
@@ -142,6 +147,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@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()
@@ -164,6 +170,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
)
end
@spec rebuild_taxonomy_stats(term()) :: term()
def rebuild_taxonomy_stats(items) do
%{
existing_count: Enum.count(items, & &1.exists_in_project),
@@ -172,9 +179,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
}
end
@spec stat_key(term()) :: term()
def stat_key(:categories), do: :category_stats
def stat_key(:tags), do: :tag_stats
@spec apply_taxonomy_mappings(term(), term()) :: term()
def apply_taxonomy_mappings(report, analysis) do
report
|> update_in(
@@ -198,6 +207,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end)
end
@spec apply_taxonomy_mapping_bucket(term(), term()) :: term()
def apply_taxonomy_mapping_bucket(items, mappings) do
Enum.map(items || [], fn item ->
case Map.fetch(mappings, item.name) do
@@ -207,6 +217,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end)
end
@spec existing_taxonomy_terms(term()) :: term()
def existing_taxonomy_terms(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
@@ -216,6 +227,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
}
end
@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()
@@ -231,6 +243,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@spec auto_mapped_count(term(), term()) :: term()
def auto_mapped_count(previous_report, next_report) do
previous_count =
(Map.get(previous_report.items, :categories, []) ++
@@ -244,6 +257,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
max(next_count - previous_count, 0)
end
@spec taxonomy_pill_class(term()) :: term()
def taxonomy_pill_class(item) do
cond do
item.exists_in_project -> "import-taxonomy-pill exists"
@@ -252,9 +266,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@spec taxonomy_item_editing?(term(), term(), term()) :: term()
def taxonomy_item_editing?(%{type: type, name: name}, type, name), do: true
def taxonomy_item_editing?(_edit, _type, _name), do: false
@spec taxonomy_mapping_tooltip(term()) :: term()
def taxonomy_mapping_tooltip(item) do
action =
if present?(item.mapped_to),
@@ -264,6 +280,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
translated("importAnalysis.mappingTooltip", %{action: action})
end
@spec maybe_put_option(term(), term(), term()) :: term()
def maybe_put_option(opts, _key, nil), do: opts
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)

View File

@@ -32,6 +32,7 @@ defmodule BDS.Desktop.ShellLive.Layout do
end
defp maybe_set_sidebar_width(workbench, nil), do: workbench
defp maybe_set_sidebar_width(workbench, width),
do: Workbench.set_sidebar_width(workbench, parse_width(width))

View File

@@ -13,14 +13,16 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
alias BDS.Repo
alias BDS.UI.Workbench
embed_templates "media_editor_html/*"
embed_templates("media_editor_html/*")
@post_picker_limit 10
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assign(socket, :media_editor, build(socket.assigns))
end
@spec update(term(), term(), term()) :: term()
def update(socket, params, reload) do
case socket.assigns.current_tab do
%{type: :media, id: media_id} ->
@@ -38,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec persist_socket(term(), term(), term(), term()) :: term()
def persist_socket(socket, media_id, reload, append_output) do
case Media.get_media(media_id) do
nil ->
@@ -52,9 +55,18 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket
|> assign(:workbench, workbench)
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved))
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media)))
|> assign(
:media_editor_drafts,
Map.delete(socket.assigns.media_editor_drafts, media_id)
)
|> assign(
:media_editor_save_states,
Map.put(socket.assigns.media_editor_save_states, media_id, :saved)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media))
)
|> reload.(workbench)
{:error, reason} ->
@@ -65,14 +77,19 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec toggle_quick_actions(term(), term(), term()) :: term()
def toggle_quick_actions(socket, media_id, reload) do
workbench = socket.assigns.workbench
socket
|> assign(:media_editor_quick_actions_open, Map.update(socket.assigns.media_editor_quick_actions_open, media_id, true, &(!&1)))
|> assign(
:media_editor_quick_actions_open,
Map.update(socket.assigns.media_editor_quick_actions_open, media_id, true, &(!&1))
)
|> reload.(workbench)
end
@spec replace_file(term(), term(), term(), term()) :: term()
def replace_file(socket, media_id, reload, append_output) do
case FilePicker.choose_file(translated("Replace Media File")) do
{:ok, source_path} ->
@@ -82,9 +99,18 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket
|> assign(:workbench, workbench)
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved))
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media)))
|> assign(
:media_editor_drafts,
Map.delete(socket.assigns.media_editor_drafts, media_id)
)
|> assign(
:media_editor_save_states,
Map.put(socket.assigns.media_editor_save_states, media_id, :saved)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media))
)
|> reload.(workbench)
{:ok, nil} ->
@@ -106,10 +132,16 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec detect_language(term(), term(), term(), term()) :: term()
def detect_language(socket, media_id, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do
socket
|> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|> append_output.(
translated("Detect Language"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)
|> reload.(socket.assigns.workbench)
else
case Media.get_media(media_id) do
@@ -118,15 +150,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
%MediaRecord{} = media ->
draft = current_draft(socket.assigns, media)
text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "alt", ""), Map.get(draft, "caption", "")], "\n\n")
text =
Enum.join(
[
Map.get(draft, "title", ""),
Map.get(draft, "alt", ""),
Map.get(draft, "caption", "")
],
"\n\n"
)
case AI.detect_language(text) do
{:ok, %{language_code: language_code}} when is_binary(language_code) and language_code != "" ->
{:ok, %{language_code: language_code}}
when is_binary(language_code) and language_code != "" ->
normalized = normalize_language(language_code)
case Media.update_media(media.id, %{language: normalized}) do
{:ok, updated_media} ->
updated_draft = Map.put(current_draft(socket.assigns, media), "language", normalized)
updated_draft =
Map.put(current_draft(socket.assigns, media), "language", normalized)
socket
|> reconcile_draft(updated_media, updated_draft)
@@ -145,17 +188,28 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
_other ->
socket
|> append_output.(translated("Detect Language"), translated("Language detection failed."), nil, "error")
|> append_output.(
translated("Detect Language"),
translated("Language detection failed."),
nil,
"error"
)
|> reload.(socket.assigns.workbench)
end
end
end
end
@spec translate(term(), term(), term(), term(), term()) :: term()
def translate(socket, media_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do
socket
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|> append_output.(
translated("Translate"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)
|> reload.(socket.assigns.workbench)
else
normalized_language = normalize_language(language)
@@ -165,8 +219,14 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
case Media.upsert_media_translation(media_id, normalized_language, translation) do
{:ok, _saved_translation} ->
socket
|> assign(:media_editor_quick_actions_open, Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false))
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|> assign(
:media_editor_quick_actions_open,
Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false)
)
|> assign(
:media_editor_translation_forms,
Map.delete(socket.assigns.media_editor_translation_forms, media_id)
)
|> reload.(socket.assigns.workbench)
{:error, reason} ->
@@ -183,6 +243,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec apply_ai_suggestions(term(), term(), term(), term(), term()) :: term()
def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do
try do
case Media.get_media(media_id) do
@@ -213,6 +274,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec delete_socket(term(), term(), term(), term()) :: term()
def delete_socket(socket, media_id, reload, append_output) do
case Media.delete_media(media_id) do
{:ok, :deleted} ->
@@ -223,11 +285,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|> assign(:shell_overlay, nil)
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id))
|> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id))
|> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id))
|> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id))
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|> assign(
:media_editor_quick_actions_open,
Map.delete(socket.assigns.media_editor_quick_actions_open, media_id)
)
|> assign(
:media_editor_post_pickers_open,
Map.delete(socket.assigns.media_editor_post_pickers_open, media_id)
)
|> assign(
:media_editor_post_picker_queries,
Map.delete(socket.assigns.media_editor_post_picker_queries, media_id)
)
|> assign(
:media_editor_save_states,
Map.delete(socket.assigns.media_editor_save_states, media_id)
)
|> assign(
:media_editor_translation_forms,
Map.delete(socket.assigns.media_editor_translation_forms, media_id)
)
|> reload.(workbench)
{:error, reason} ->
@@ -237,28 +314,43 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec toggle_post_picker(term(), term(), term()) :: term()
def toggle_post_picker(socket, media_id, reload) do
workbench = socket.assigns.workbench
socket
|> assign(:media_editor_post_pickers_open, Map.update(socket.assigns.media_editor_post_pickers_open, media_id, true, &(!&1)))
|> assign(
:media_editor_post_pickers_open,
Map.update(socket.assigns.media_editor_post_pickers_open, media_id, true, &(!&1))
)
|> reload.(workbench)
end
@spec set_post_picker_query(term(), term(), term(), term()) :: term()
def set_post_picker_query(socket, media_id, query, reload) do
workbench = socket.assigns.workbench
socket
|> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, to_string(query || "")))
|> assign(
:media_editor_post_picker_queries,
Map.put(socket.assigns.media_editor_post_picker_queries, media_id, to_string(query || ""))
)
|> reload.(workbench)
end
@spec link_post(term(), term(), term(), term(), term()) :: term()
def link_post(socket, media_id, post_id, reload, append_output) do
case Media.link_media_to_post(media_id, post_id) do
{:ok, _linked} ->
socket
|> assign(:media_editor_post_pickers_open, Map.put(socket.assigns.media_editor_post_pickers_open, media_id, false))
|> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, ""))
|> assign(
:media_editor_post_pickers_open,
Map.put(socket.assigns.media_editor_post_pickers_open, media_id, false)
)
|> assign(
:media_editor_post_picker_queries,
Map.put(socket.assigns.media_editor_post_picker_queries, media_id, "")
)
|> reload.(socket.assigns.workbench)
{:error, reason} ->
@@ -268,6 +360,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec unlink_post(term(), term(), term(), term(), term()) :: term()
def unlink_post(socket, media_id, post_id, reload, append_output) do
case Media.unlink_media_from_post(media_id, post_id) do
{:ok, _unlinked} ->
@@ -280,6 +373,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec edit_translation(term(), term(), term(), term()) :: term()
def edit_translation(socket, media_id, language, reload) do
workbench = socket.assigns.workbench
@@ -287,16 +381,20 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
form = %{
"language" => language,
"title" => translation && translation.title || "",
"alt" => translation && translation.alt || "",
"caption" => translation && translation.caption || ""
"title" => (translation && translation.title) || "",
"alt" => (translation && translation.alt) || "",
"caption" => (translation && translation.caption) || ""
}
socket
|> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form))
|> assign(
:media_editor_translation_forms,
Map.put(socket.assigns.media_editor_translation_forms, media_id, form)
)
|> reload.(workbench)
end
@spec update_translation(term(), term(), term(), term()) :: term()
def update_translation(socket, media_id, params, reload) do
workbench = socket.assigns.workbench
@@ -308,10 +406,14 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
}
socket
|> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form))
|> assign(
:media_editor_translation_forms,
Map.put(socket.assigns.media_editor_translation_forms, media_id, form)
)
|> reload.(workbench)
end
@spec save_translation(term(), term(), term(), term()) :: term()
def save_translation(socket, media_id, reload, append_output) do
case Map.get(socket.assigns.media_editor_translation_forms, media_id) do
%{"language" => language} = form when language not in [nil, ""] ->
@@ -322,7 +424,10 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
}) do
{:ok, _translation} ->
socket
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|> assign(
:media_editor_translation_forms,
Map.delete(socket.assigns.media_editor_translation_forms, media_id)
)
|> reload.(socket.assigns.workbench)
{:error, reason} ->
@@ -336,16 +441,23 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec refresh_translation(term(), term(), term(), term(), term()) :: term()
def refresh_translation(socket, media_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do
socket
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|> append_output.(
translated("Translate"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)
|> reload.(socket.assigns.workbench)
else
case AI.translate_media(media_id, normalize_language(language)) do
{:ok, translation} ->
case Media.upsert_media_translation(media_id, language, translation) do
{:ok, _saved_translation} -> socket |> reload.(socket.assigns.workbench)
{:ok, _saved_translation} ->
socket |> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
@@ -361,11 +473,15 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec delete_translation(term(), term(), term(), term(), term()) :: term()
def delete_translation(socket, media_id, language, reload, append_output) do
case Media.delete_media_translation(media_id, language) do
{:ok, _deleted?} ->
socket
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|> assign(
:media_editor_translation_forms,
Map.delete(socket.assigns.media_editor_translation_forms, media_id)
)
|> reload.(socket.assigns.workbench)
{:error, reason} ->
@@ -375,6 +491,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do
case Media.get_media(media_id) do
nil ->
@@ -385,7 +502,9 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
translations = Media.list_media_translations(media.id)
form = current_draft(assigns, media)
picker_query = Map.get(assigns.media_editor_post_picker_queries, media.id, "")
{picker_results, picker_overflow_count} = post_picker_results(media, linked_posts, picker_query)
{picker_results, picker_overflow_count} =
post_picker_results(media, linked_posts, picker_query)
%{
id: media.id,
@@ -416,20 +535,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
def build(_assigns), do: nil
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec media_editor_save_state_label(term()) :: term()
def media_editor_save_state_label(:dirty), do: translated("Unsaved")
def media_editor_save_state_label(:saved), do: translated("Saved")
def media_editor_save_state_label(_state), do: translated("Idle")
@spec language_label(term()) :: term()
def language_label(code) do
code
|> to_string()
|> String.upcase()
end
@spec normalize_language(term()) :: term()
def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase()
@spec persist(term(), term()) :: term()
def persist(%MediaRecord{} = media, draft) do
Media.update_media(media.id, %{
title: blank_to_nil(Map.get(draft, "title")),
@@ -444,7 +569,11 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
defp reconcile_draft(socket, %MediaRecord{} = media, draft) do
persisted = persisted_form(media)
dirty? = draft != persisted
workbench = if dirty?, do: Workbench.mark_dirty(socket.assigns.workbench, :media, media.id), else: Workbench.clear_dirty(socket.assigns.workbench, :media, media.id)
workbench =
if dirty?,
do: Workbench.mark_dirty(socket.assigns.workbench, :media, media.id),
else: Workbench.clear_dirty(socket.assigns.workbench, :media, media.id)
drafts =
if dirty? do
@@ -456,8 +585,21 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket
|> assign(:workbench, workbench)
|> assign(:media_editor_drafts, drafts)
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media.id, if(dirty?, do: :dirty, else: :idle)))
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media.id}, %{title: blank_to_nil(Map.get(draft, "title")) || display_title(media), subtitle: media.original_name || media.mime_type || ""}))
|> assign(
:media_editor_save_states,
Map.put(
socket.assigns.media_editor_save_states,
media.id,
if(dirty?, do: :dirty, else: :idle)
)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:media, media.id}, %{
title: blank_to_nil(Map.get(draft, "title")) || display_title(media),
subtitle: media.original_name || media.mime_type || ""
})
)
end
defp current_draft(assigns, %MediaRecord{} = media) do
@@ -505,10 +647,15 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
from post in Post,
where: post.project_id == ^media.project_id,
order_by: [desc: post.updated_at, desc: post.created_at],
select: %{post_id: post.id, title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id)}
select: %{
post_id: post.id,
title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id)
}
)
|> Enum.reject(&MapSet.member?(linked_ids, &1.post_id))
|> Enum.filter(fn post -> normalized_query == "" or String.contains?(String.downcase(post.title), normalized_query) end)
|> Enum.filter(fn post ->
normalized_query == "" or String.contains?(String.downcase(post.title), normalized_query)
end)
{Enum.take(posts, @post_picker_limit), max(length(posts) - @post_picker_limit, 0)}
end
@@ -518,18 +665,28 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
defp preview_url(%MediaRecord{} = media) do
if image?(media), do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}", else: nil
if image?(media),
do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}",
else: nil
end
defp image?(%MediaRecord{} = media), do: String.starts_with?(to_string(media.mime_type || ""), "image/")
defp image?(%MediaRecord{} = media),
do: String.starts_with?(to_string(media.mime_type || ""), "image/")
defp display_title(%MediaRecord{} = media), do: blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id
defp display_title(%MediaRecord{} = media),
do: blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id
defp dimensions_label(%MediaRecord{width: width, height: height})
when is_integer(width) and is_integer(height), do: "#{width} x #{height}"
defp dimensions_label(%MediaRecord{width: width, height: height}) when is_integer(width) and is_integer(height), do: "#{width} x #{height}"
defp dimensions_label(_media), do: nil
defp format_file_size(size) when is_integer(size) and size >= 1_048_576, do: :erlang.float_to_binary(size / 1_048_576, decimals: 1) <> " MB"
defp format_file_size(size) when is_integer(size), do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
defp format_file_size(size) when is_integer(size) and size >= 1_048_576,
do: :erlang.float_to_binary(size / 1_048_576, decimals: 1) <> " MB"
defp format_file_size(size) when is_integer(size),
do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
defp format_file_size(_size), do: "0.0 KB"
defp detect_language_enabled?(form) do
@@ -567,5 +724,6 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
defp reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench)
defp reload_with_assigned_workbench(socket, reload),
do: reload.(socket, socket.assigns.workbench)
end

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
use Phoenix.Component
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.MenuEditor.{
DraftManagement,
PageCategory,
@@ -12,8 +13,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
TreePredicates
}
embed_templates "menu_editor_html/*"
embed_templates("menu_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
case socket.assigns[:current_tab] do
%{type: :menu_editor, id: tab_id} ->
@@ -36,12 +38,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec select_item(term(), term(), term()) :: term()
def select_item(socket, item_id, reload) do
socket
|> State.update_state(fn state -> %{state | selected_id: item_id} end)
|> reload.(socket.assigns.workbench)
end
@spec change_entry(term(), term(), term()) :: term()
def change_entry(socket, params, reload) do
query = Map.get(params, "query", "")
@@ -50,6 +54,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|> reload.(socket.assigns.workbench)
end
@spec submit_entry(term(), term()) :: term()
def submit_entry(socket, reload) do
case DraftManagement.current_draft(socket.assigns) do
%{type: :page} ->
@@ -67,12 +72,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec cancel_entry(term(), term()) :: term()
def cancel_entry(socket, reload) do
socket
|> State.update_state(&DraftManagement.cancel_draft/1)
|> reload.(socket.assigns.workbench)
end
@spec select_page(term(), term(), term()) :: term()
def select_page(socket, post_id, reload) do
case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do
nil ->
@@ -85,6 +92,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec select_category(term(), term(), term()) :: term()
def select_category(socket, name, reload) do
project_id = socket.assigns.projects.active_project_id
@@ -99,6 +107,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec toolbar_action(term(), term(), term(), term()) :: term()
def toolbar_action(socket, action, reload, append_output) do
case action do
"add-entry" ->
@@ -144,12 +153,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec drop_item(term(), term(), term(), term(), term()) :: term()
def drop_item(socket, drag_item_id, target_item_id, position, reload) do
socket
|> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position))
|> reload.(socket.assigns.workbench)
end
@spec handle_keydown(term(), term(), term()) :: term()
def handle_keydown(socket, "Escape", reload) do
cancel_entry(socket, reload)
end
@@ -158,14 +169,16 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
reload.(socket, socket.assigns.workbench)
end
attr :menu_editor, :map, required: true
attr(:menu_editor, :map, required: true)
@spec menu_editor(term()) :: term()
def menu_editor(assigns)
attr :items, :list, required: true
attr :menu_editor, :map, required: true
attr :depth, :integer, required: true
attr(:items, :list, required: true)
attr(:menu_editor, :map, required: true)
attr(:depth, :integer, required: true)
@spec menu_tree_level(term()) :: term()
def menu_tree_level(assigns) do
~H"""
<%= for item <- @items do %>
@@ -289,8 +302,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
"""
end
attr :kind, :atom, required: true
attr(:kind, :atom, required: true)
@spec kind_icon(term()) :: term()
def kind_icon(assigns) do
~H"""
<%= case @kind do %>
@@ -306,9 +320,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
"""
end
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec row_label(term(), term()) :: term()
def row_label(item, category_titles) do
if item.kind == :category_archive do
Map.get(category_titles || %{}, item.slug, item.label)
@@ -317,6 +333,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec kind_label(term()) :: term()
def kind_label(:home), do: translated("menuEditor.type.home")
def kind_label(:page), do: translated("menuEditor.type.page")
def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive")
@@ -324,12 +341,17 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
defdelegate draft_item?(menu_editor, item_id), to: TreePredicates
@spec editing_title(term()) :: term()
def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive")
def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title")
@spec editing_hint(term()) :: term()
def editing_hint(%{draft: %{type: :category}}), do: translated("menuEditor.categoryPicker.hint")
def editing_hint(_menu_editor), do: translated("menuEditor.createHint")
def editing_placeholder(%{draft: %{type: :category}}), do: translated("menuEditor.newCategoryPlaceholder")
@spec editing_placeholder(term()) :: term()
def editing_placeholder(%{draft: %{type: :category}}),
do: translated("menuEditor.newCategoryPlaceholder")
def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder")
end

View File

@@ -6,8 +6,10 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
alias BDS.Desktop.ShellLive.MenuEditor.PageCategory
alias BDS.Desktop.ShellLive.MenuEditor.TreeOps
@spec current_draft(term()) :: term()
def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft)
@spec start_page_draft(term()) :: term()
def start_page_draft(state) do
item = %{
item_id: Ecto.UUID.generate(),
@@ -29,6 +31,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
}
end
@spec start_category_draft(term()) :: term()
def start_category_draft(state) do
item = %{
item_id: Ecto.UUID.generate(),
@@ -50,6 +53,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
}
end
@spec finalize_submenu_draft(term()) :: term()
def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do
label =
if(String.trim(query) == "",
@@ -69,12 +73,19 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def finalize_submenu_draft(state), do: state
@spec assign_page_to_draft(term(), term()) :: term()
def assign_page_to_draft(%{draft: %{item_id: item_id}} = state, post) do
%{
state
| items:
TreeOps.update_item(state.items, item_id, fn item ->
%{item | kind: :page, label: post.title, slug: PageCategory.blank_to_nil(post.slug), children: []}
%{
item
| kind: :page,
label: post.title,
slug: PageCategory.blank_to_nil(post.slug),
children: []
}
end),
draft: nil
}
@@ -82,6 +93,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def assign_page_to_draft(state, _post), do: state
@spec assign_category_to_draft(term(), term()) :: term()
def assign_category_to_draft(%{draft: %{item_id: item_id}} = state, category) do
label = PageCategory.blank_to_nil(category.title) || category.name
@@ -97,6 +109,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def assign_category_to_draft(state, _category), do: state
@spec cancel_draft(term()) :: term()
def cancel_draft(%{draft: %{item_id: item_id}} = state) do
items = TreeOps.remove_item(state.items, item_id)
%{state | items: items, selected_id: TreeOps.first_item_id(items), draft: nil}
@@ -104,6 +117,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def cancel_draft(state), do: state
@spec confirm_category_draft(term(), term()) :: term()
def confirm_category_draft(socket, update_state_fun) do
project_id = socket.assigns.projects.active_project_id
draft = current_draft(socket.assigns)
@@ -117,8 +131,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
category =
cond do
category != nil -> category
normalized == "" -> %{name: "", title: ""}
category != nil ->
category
normalized == "" ->
%{name: "", title: ""}
true ->
{:ok, _metadata} = Metadata.add_category(project_id, normalized)
%{name: normalized, title: normalized}

View File

@@ -6,19 +6,26 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do
alias BDS.{Metadata, Repo}
alias BDS.Posts.Post
@spec page_posts(term()) :: term()
def page_posts(nil), do: []
def page_posts(project_id) do
Repo.all(from post in Post, where: post.project_id == ^project_id, order_by: [asc: post.title, asc: post.slug])
Repo.all(
from post in Post,
where: post.project_id == ^project_id,
order_by: [asc: post.title, asc: post.slug]
)
|> Enum.filter(&("page" in (&1.categories || [])))
end
@spec page_post(term(), term()) :: term()
def page_post(nil, _post_id), do: nil
def page_post(project_id, post_id) do
Enum.find(page_posts(project_id), &(&1.id == post_id))
end
@spec filter_page_posts(term(), term()) :: term()
def filter_page_posts(posts, query) do
normalized = query |> to_string() |> String.trim() |> String.downcase()
@@ -29,6 +36,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do
end)
end
@spec category_options(term()) :: term()
def category_options(nil), do: []
def category_options(project_id) do
@@ -40,6 +48,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do
end)
end
@spec filter_categories(term(), term()) :: term()
def filter_categories(categories, query) do
normalized = query |> to_string() |> String.trim() |> String.downcase()
@@ -50,7 +59,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do
end)
end
@spec blank_to_nil(term()) :: term()
def blank_to_nil(nil), do: nil
def blank_to_nil(value) do
trimmed = String.trim(to_string(value))
if trimmed == "", do: nil, else: trimmed

View File

@@ -7,6 +7,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
alias BDS.Menu
alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates}
@spec ensure_state(term()) :: term()
def ensure_state(assigns) do
project_id = assigns.projects.active_project_id
@@ -16,11 +17,13 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
end
end
@spec update_state(term(), term()) :: term()
def update_state(socket, updater) do
state = ensure_state(socket.assigns)
assign(socket, :menu_editor_state, updater.(state))
end
@spec build(term(), term()) :: term()
def build(_assigns, state) do
categories = PageCategory.category_options(state.project_id)
draft = state.draft
@@ -35,7 +38,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
draft_query: draft_query,
filtered_pages:
if(match?(%{type: :page}, draft),
do: PageCategory.filter_page_posts(PageCategory.page_posts(state.project_id), draft_query),
do:
PageCategory.filter_page_posts(PageCategory.page_posts(state.project_id), draft_query),
else: []
),
filtered_categories:
@@ -53,6 +57,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
}
end
@spec save(term(), term(), term()) :: term()
def save(socket, reload, append_output) do
state = socket.assigns.menu_editor_state
@@ -60,12 +65,22 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
Menu.update_menu(state.project_id, Enum.map(state.items, &TreeOps.persisted_item/1))
socket
|> append_output.(translated("menuEditor.tabTitle"), translated("menuEditor.saved"), nil, "info")
|> append_output.(
translated("menuEditor.tabTitle"),
translated("menuEditor.saved"),
nil,
"info"
)
|> reload.(socket.assigns.workbench)
end
defp load_state(nil) do
%{project_id: nil, items: [TreeOps.home_item()], selected_id: TreeOps.home_item_id(), draft: nil}
%{
project_id: nil,
items: [TreeOps.home_item()],
selected_id: TreeOps.home_item_id(),
draft: nil
}
end
defp load_state(project_id) do

View File

@@ -3,12 +3,15 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
@home_item_id "menu-home"
@spec home_item_id() :: term()
def home_item_id, do: @home_item_id
@spec home_item() :: term()
def home_item do
%{item_id: @home_item_id, kind: :home, label: "Home", slug: nil, children: [], is_home: true}
end
@spec ui_item(term()) :: term()
def ui_item(%{kind: :home}), do: home_item()
def ui_item(item) do
@@ -24,25 +27,37 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
}
end
@spec persisted_item(term()) :: term()
def persisted_item(%{kind: :home}), do: %{kind: :home, label: "Home", slug: nil}
def persisted_item(%{kind: :submenu} = item) do
%{kind: :submenu, label: item.label, slug: nil, children: Enum.map(item.children || [], &persisted_item/1)}
%{
kind: :submenu,
label: item.label,
slug: nil,
children: Enum.map(item.children || [], &persisted_item/1)
}
end
def persisted_item(item) do
%{kind: item.kind, label: item.label, slug: item.slug}
end
@spec first_item_id(term()) :: term()
def first_item_id([item | _rest]), do: item.item_id
def first_item_id([]), do: nil
@spec insert_target(term(), term()) :: term()
def insert_target(items, nil), do: {[], length(items)}
def insert_target(items, selected_id) do
case find_path(items, selected_id) do
nil -> {[], length(items)}
[] -> {[], length(items)}
nil ->
{[], length(items)}
[] ->
{[], length(items)}
path ->
case item_at_path(items, path) do
%{kind: :submenu} -> {path, 0}
@@ -51,9 +66,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end
end
@spec path_prefix?(term(), term()) :: term()
def path_prefix?(prefix, path) when length(prefix) > length(path), do: false
@spec path_prefix?(term(), term()) :: term()
def path_prefix?(prefix, path), do: Enum.take(path, length(prefix)) == prefix
@spec find_path(term(), term(), term()) :: term()
def find_path(items, item_id, path \\ []) do
Enum.find_value(Enum.with_index(items), fn {item, index} ->
next_path = path ++ [index]
@@ -71,6 +89,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end)
end
@spec item_at_path(term(), term()) :: term()
def item_at_path(_items, []), do: nil
def item_at_path(items, [index]) do
@@ -84,6 +103,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end
end
@spec items_at_path(term(), term()) :: term()
def items_at_path(items, []), do: items
def items_at_path(items, [index | rest]) do
@@ -93,6 +113,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end
end
@spec replace_items_at_path(term(), term(), term()) :: term()
def replace_items_at_path(_items, [], replacement), do: replacement
def replace_items_at_path(items, [index | rest], replacement) do
@@ -101,6 +122,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end)
end
@spec update_item(term(), term(), term()) :: term()
def update_item(items, item_id, updater) do
Enum.map(items, fn item ->
cond do
@@ -111,6 +133,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end)
end
@spec insert_item(term(), term(), term(), term()) :: term()
def insert_item(items, [], index, item) do
List.insert_at(items, index, item)
end
@@ -121,10 +144,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end)
end
@spec remove_item(term(), term()) :: term()
def remove_item(items, item_id) do
remove_item_with_value(items, item_id) |> elem(0)
end
@spec remove_item_with_value(term(), term()) :: term()
def remove_item_with_value(items, item_id) do
Enum.reduce_while(Enum.with_index(items), {items, nil}, fn {item, index}, _acc ->
cond do
@@ -135,7 +160,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
{next_children, removed_item} = remove_item_with_value(item.children, item_id)
if removed_item do
{:halt, {List.replace_at(items, index, %{item | children: next_children}), removed_item}}
{:halt,
{List.replace_at(items, index, %{item | children: next_children}), removed_item}}
else
{:cont, {items, nil}}
end
@@ -146,16 +172,23 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end)
end
@spec append_child(term(), term(), term()) :: term()
def append_child(items, parent_item_id, child) do
update_item(items, parent_item_id, fn item ->
%{item | children: (item.children || []) ++ [child]}
end)
end
def move_selected(%{selected_id: selected_id} = state, direction) when direction in [:up, :down] do
@spec move_selected(term(), term()) :: term()
def move_selected(%{selected_id: selected_id} = state, direction)
when direction in [:up, :down] do
case find_path(state.items, selected_id) do
nil -> state
[] -> state
nil ->
state
[] ->
state
path ->
parent_path = Enum.drop(path, -1)
index = List.last(path)
@@ -175,10 +208,15 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end
end
@spec indent_selected(term()) :: term()
def indent_selected(%{selected_id: selected_id} = state) do
case find_path(state.items, selected_id) do
nil -> state
[] -> state
nil ->
state
[] ->
state
path ->
parent_path = Enum.drop(path, -1)
index = List.last(path)
@@ -193,7 +231,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
case item_at_path(state.items, previous_sibling_path) do
%{kind: :submenu, item_id: sibling_id} ->
case remove_item_with_value(state.items, selected_id) do
{_next_items, nil} -> state
{_next_items, nil} ->
state
{next_items, removed_item} ->
%{
state
@@ -208,18 +248,27 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end
end
@spec unindent_selected(term()) :: term()
def unindent_selected(%{selected_id: selected_id} = state) do
case find_path(state.items, selected_id) do
nil -> state
[] -> state
[_root_index] -> state
nil ->
state
[] ->
state
[_root_index] ->
state
path ->
parent_path = Enum.drop(path, -1)
parent_index = List.last(parent_path)
grand_parent_path = Enum.drop(parent_path, -1)
case remove_item_with_value(state.items, selected_id) do
{_next_items, nil} -> state
{_next_items, nil} ->
state
{next_items, removed_item} ->
%{
state
@@ -229,6 +278,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end
end
@spec delete_selected(term()) :: term()
def delete_selected(%{selected_id: @home_item_id} = state), do: state
def delete_selected(%{selected_id: selected_id} = state) do
@@ -241,9 +291,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
state
end
def drop_selected(state, drag_item_id, target_item_id, _position) when drag_item_id == target_item_id,
do: state
def drop_selected(state, drag_item_id, target_item_id, _position)
when drag_item_id == target_item_id,
do: state
@spec drop_selected(term(), term(), term(), term()) :: term()
def drop_selected(state, drag_item_id, target_item_id, position) do
drag_path = find_path(state.items, drag_item_id)
target_path = find_path(state.items, target_item_id)
@@ -275,7 +327,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
defp insert_dropped_item(state, next_items, dragged_item, target_path, "inside") do
case item_at_path(next_items, target_path) do
%{kind: :submenu} ->
%{state | items: insert_item(next_items, target_path, 0, dragged_item), selected_id: dragged_item.item_id}
%{
state
| items: insert_item(next_items, target_path, 0, dragged_item),
selected_id: dragged_item.item_id
}
_other ->
state
@@ -285,12 +341,22 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
defp insert_dropped_item(state, next_items, dragged_item, target_path, "before") do
parent_path = Enum.drop(target_path, -1)
index = List.last(target_path)
%{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id}
%{
state
| items: insert_item(next_items, parent_path, index, dragged_item),
selected_id: dragged_item.item_id
}
end
defp insert_dropped_item(state, next_items, dragged_item, target_path, _position) do
parent_path = Enum.drop(target_path, -1)
index = List.last(target_path) + 1
%{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id}
%{
state
| items: insert_item(next_items, parent_path, index, dragged_item),
selected_id: dragged_item.item_id
}
end
end

View File

@@ -3,6 +3,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
alias BDS.Desktop.ShellLive.MenuEditor.TreeOps
@spec can_move_up?(term(), term()) :: term()
def can_move_up?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do
[_parent, index] -> index > 0
@@ -12,9 +13,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
end
end
@spec can_move_down?(term(), term()) :: term()
def can_move_down?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do
nil -> false
nil ->
false
path ->
parent_path = Enum.drop(path, -1)
index = List.last(path)
@@ -22,10 +26,15 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
end
end
@spec can_indent?(term(), term()) :: term()
def can_indent?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do
nil -> false
[] -> false
nil ->
false
[] ->
false
[_index] = path ->
index = List.last(path)
index > 0 and match?(%{kind: :submenu}, TreeOps.item_at_path(items, [index - 1]))
@@ -34,10 +43,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
index = List.last(path)
index > 0 and
match?(%{kind: :submenu}, TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1]))
match?(
%{kind: :submenu},
TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1])
)
end
end
@spec can_unindent?(term(), term()) :: term()
def can_unindent?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do
[_index] -> false
@@ -46,9 +59,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
end
end
@spec can_delete?(term()) :: term()
def can_delete?(selected_id),
do: is_binary(selected_id) and selected_id != TreeOps.home_item_id()
@spec draft_item?(term(), term()) :: term()
def draft_item?(menu_editor, item_id) do
match?(%{item_id: ^item_id}, menu_editor.draft)
end

View File

@@ -20,10 +20,12 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
:git_diff
]
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assign(socket, :misc_editor, build(socket.assigns))
end
@spec rerun(term()) :: term()
def rerun(socket) do
case meta(socket.assigns) do
%{action: action} when is_binary(action) ->
@@ -37,6 +39,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec apply_site_validation(term(), term()) :: term()
def apply_site_validation(socket, append_output) do
meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{})
@@ -68,6 +71,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")}
end
@spec toggle_duplicate(term(), term(), term()) :: term()
def toggle_duplicate(socket, pair_id, reload) do
selected_by_tab = Map.get(socket.assigns, :misc_editor_selected_pairs, %{})
current = Map.get(selected_by_tab, socket.assigns.current_tab.id, MapSet.new())
@@ -87,6 +91,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|> reload.(socket.assigns.workbench)
end
@spec dismiss_duplicate(term(), term(), term(), term(), term()) :: term()
def dismiss_duplicate(socket, post_id_a, post_id_b, reload, append_output) do
case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do
{:ok, _saved_pair} ->
@@ -109,6 +114,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec dismiss_selected(term(), term(), term()) :: term()
def dismiss_selected(socket, reload, append_output) do
tab_id = socket.assigns.current_tab.id
@@ -141,6 +147,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec fix_translation_validation(term(), term()) :: term()
def fix_translation_validation(socket, append_output) do
report =
socket.assigns
@@ -166,6 +173,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")}
end
@spec select_git_diff_file(term(), term()) :: term()
def select_git_diff_file(socket, file_path) do
assign(
socket,
@@ -178,6 +186,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
)
end
@spec metadata_diff_repair_request(term(), term(), term()) :: term()
def metadata_diff_repair_request(socket, field, direction) do
meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{})
@@ -209,6 +218,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec metadata_diff_orphan_import_request(term()) :: term()
def metadata_diff_orphan_import_request(socket) do
meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{})
@@ -232,6 +242,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec build(term()) :: term()
def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do
meta = meta(assigns)
payload = Map.get(meta, :payload, %{})
@@ -245,11 +256,14 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec build(term()) :: term()
def build(_assigns), do: nil
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec misc_class(term()) :: term()
def misc_class(:site_validation), do: "site-validation-view"
def misc_class(:metadata_diff), do: "metadata-diff-view"
def misc_class(:translation_validation), do: "translation-validation-view"
@@ -257,10 +271,13 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
def misc_class(:git_diff), do: "git-diff-view"
def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary)
@spec summary_items(term()) :: term()
def summary_items(_misc), do: []
@spec duplicate_checked?(term(), term()) :: term()
def duplicate_checked?(misc, pair_id), do: MapSet.member?(misc.selected_pairs, pair_id)
@spec pair_id_from_pair(term()) :: term()
def pair_id_from_pair(pair), do: pair_identity(pair)
defp build_site_validation(meta, payload) do
@@ -410,6 +427,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
}
end
@spec translation_issue_label(term()) :: term()
def translation_issue_label(issue) do
case issue_value(issue, :issue) do
"same-language-as-canonical" ->
@@ -426,6 +444,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec translation_issue_languages(term()) :: term()
def translation_issue_languages(issue) do
canonical_language = issue_value(issue, :canonical_language)
translation_language = issue_value(issue, :translation_language)
@@ -440,8 +459,10 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec translation_issue_value(term(), term()) :: term()
def translation_issue_value(issue, key), do: issue_value(issue, key)
@spec git_diff_language(term()) :: term()
def git_diff_language(nil), do: "plaintext"
def git_diff_language(file_path) do

View File

@@ -12,7 +12,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
alias BDS.Posts.{Post, PostMedia, Translation}
alias BDS.Tags.Tag
embed_templates "overlay_html/*"
embed_templates("overlay_html/*")
def context(assigns, tab_title, tab_subtitle) do
project_id = assigns.projects.active_project_id
@@ -23,7 +23,12 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
media = media(project_id)
%{
current_tab: %{type: current_tab.type, id: current_tab.id, title: tab_title, subtitle: tab_subtitle},
current_tab: %{
type: current_tab.type,
id: current_tab.id,
title: tab_title,
subtitle: tab_subtitle
},
current_post_language: source_language(current_tab, metadata),
current_media_language: source_language(current_tab, metadata),
posts: posts,
@@ -59,7 +64,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
def markdown_link(text, url), do: "[#{text}](#{url})"
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
@@ -77,7 +83,15 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
from post in Post,
where: post.project_id == ^project_id,
order_by: [desc: post.updated_at, desc: post.created_at],
select: %{id: post.id, title: post.title, slug: post.slug, status: post.status, published_at: post.published_at, updated_at: post.updated_at, language: post.language}
select: %{
id: post.id,
title: post.title,
slug: post.slug,
status: post.status,
published_at: post.published_at,
updated_at: post.updated_at,
language: post.language
}
)
|> Enum.map(fn post ->
%{
@@ -96,7 +110,14 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
from media in MediaRecord,
where: media.project_id == ^project_id,
order_by: [desc: media.updated_at, desc: media.created_at],
select: %{id: media.id, title: media.title, original_name: media.original_name, mime_type: media.mime_type, alt: media.alt, caption: media.caption}
select: %{
id: media.id,
title: media.title,
original_name: media.original_name,
mime_type: media.mime_type,
alt: media.alt,
caption: media.caption
}
)
|> Enum.map(fn media ->
%{
@@ -149,7 +170,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
defp existing_translations(_tab), do: %{}
defp blog_languages(metadata) do
([metadata.main_language || "en"] ++ (metadata.blog_languages || []) ++ Enum.map(I18n.supported_languages(), & &1.code))
([metadata.main_language || "en"] ++
(metadata.blog_languages || []) ++ Enum.map(I18n.supported_languages(), & &1.code))
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
end
@@ -193,9 +215,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
case Posts.get_post(post_id) do
%Post{} = post ->
[
%{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: post.title || title, suggested_value: refine_title(post.title || title), locked: false},
%{key: "excerpt", label: ShellData.translate("Excerpt", %{}, page_language), current_value: post.excerpt || subtitle, suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), locked: false},
%{key: "slug", label: ShellData.translate("Slug", %{}, page_language), current_value: post.slug || slugify(post.title || title), suggested_value: refine_slug(post.slug || slugify(post.title || title)), locked: post.status == :published}
%{
key: "title",
label: ShellData.translate("Title", %{}, page_language),
current_value: post.title || title,
suggested_value: refine_title(post.title || title),
locked: false
},
%{
key: "excerpt",
label: ShellData.translate("Excerpt", %{}, page_language),
current_value: post.excerpt || subtitle,
suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle),
locked: false
},
%{
key: "slug",
label: ShellData.translate("Slug", %{}, page_language),
current_value: post.slug || slugify(post.title || title),
suggested_value: refine_slug(post.slug || slugify(post.title || title)),
locked: post.status == :published
}
]
_other ->
@@ -209,9 +249,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
case Media.get_media(media_id) do
%MediaRecord{} = media ->
[
%{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: media.title || title, suggested_value: refine_title(media.title || title), locked: false},
%{key: "alt", label: ShellData.translate("Alt Text", %{}, page_language), current_value: media.alt || "", suggested_value: media.alt || title, locked: false},
%{key: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", suggested_value: refine_excerpt(title, media.caption || title), locked: false}
%{
key: "title",
label: ShellData.translate("Title", %{}, page_language),
current_value: media.title || title,
suggested_value: refine_title(media.title || title),
locked: false
},
%{
key: "alt",
label: ShellData.translate("Alt Text", %{}, page_language),
current_value: media.alt || "",
suggested_value: media.alt || title,
locked: false
},
%{
key: "caption",
label: ShellData.translate("Caption", %{}, page_language),
current_value: media.caption || "",
suggested_value: refine_excerpt(title, media.caption || title),
locked: false
}
]
_other ->
@@ -248,7 +306,13 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: reference_list
}
rescue
_error -> %{title: ShellData.translate("Delete Media", %{}, page_language), entity_name: media_id, entity_type: "media", reference_list: []}
_error ->
%{
title: ShellData.translate("Delete Media", %{}, page_language),
entity_name: media_id,
entity_type: "media",
reference_list: []
}
end
defp delete_details(%{type: :tags}, page_language) do
@@ -263,16 +327,33 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: []
}
rescue
_error -> %{title: ShellData.translate("Delete Tag", %{}, page_language), entity_name: "tag", entity_type: "tag", reference_list: []}
_error ->
%{
title: ShellData.translate("Delete Tag", %{}, page_language),
entity_name: "tag",
entity_type: "tag",
reference_list: []
}
end
defp delete_details(_tab, page_language) do
%{title: ShellData.translate("Delete", %{}, page_language), entity_name: "", entity_type: "item", reference_list: []}
%{
title: ShellData.translate("Delete", %{}, page_language),
entity_name: "",
entity_type: "item",
reference_list: []
}
end
defp merge_details(project_id, page_language) do
tags =
Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name], limit: 3, select: tag.name)
Repo.all(
from tag in Tag,
where: tag.project_id == ^project_id,
order_by: [asc: tag.name],
limit: 3,
select: tag.name
)
target = List.first(tags) || "tag"
@@ -283,7 +364,13 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
message: ShellData.translate("Cannot be undone.", %{}, page_language)
}
rescue
_error -> %{target: "tag", count: 1, title: ShellData.translate("Merge Tags", %{}, page_language), message: ShellData.translate("Cannot be undone.", %{}, page_language)}
_error ->
%{
target: "tag",
count: 1,
title: ShellData.translate("Merge Tags", %{}, page_language),
message: ShellData.translate("Cannot be undone.", %{}, page_language)
}
end
defp canonical_post_url(post) do
@@ -302,7 +389,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
if base == "", do: "#{title} overview", else: base <> "."
end
defp refine_slug(slug), do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated")
defp refine_slug(slug),
do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated")
defp slugify(value) do
value

View File

@@ -210,8 +210,15 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
defp related_posts(links, key) do
Enum.map(links, fn link ->
case Posts.get_post(Map.fetch!(link, key)) do
%Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id}
_other -> nil
%Post{} = post ->
%{
id: post.id,
title: post.title || post.slug || post.id,
text: link.link_text || post.slug || post.id
}
_other ->
nil
end
end)
|> Enum.reject(&is_nil/1)
@@ -232,15 +239,22 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
defp git_history_target(%{type: :post, id: post_id}) do
case Posts.get_post(post_id) do
%Post{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path}
_other -> nil
%Post{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] ->
{project_id, file_path}
_other ->
nil
end
end
defp git_history_target(%{type: :media, id: media_id}) do
case Media.get_media(media_id) do
%MediaRecord{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path}
_other -> nil
%MediaRecord{project_id: project_id, file_path: file_path}
when file_path not in [nil, ""] ->
{project_id, file_path}
_other ->
nil
end
end
@@ -287,5 +301,6 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
defp present?(value), do: value not in [nil, ""]
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -74,13 +74,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
defdelegate tag_chip_style(color), to: ListValues
embed_templates "post_editor_html/*"
embed_templates("post_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assigns = Map.put(socket.assigns, :project_metadata, project_metadata(socket.assigns.projects.active_project_id))
assigns =
Map.put(
socket.assigns,
:project_metadata,
project_metadata(socket.assigns.projects.active_project_id)
)
assign(socket, :post_editor, build(assigns))
end
@spec update(term(), term(), term()) :: term()
def update(socket, params, reload) do
case socket.assigns.current_tab do
%{type: :post, id: post_id} ->
@@ -91,7 +99,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata)
current_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
current_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
requested_language = normalize_language(Map.get(params, "language"), current_language)
next_language =
@@ -117,6 +128,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec persist_socket(term(), term(), term(), term(), term()) :: term()
def persist_socket(socket, post_id, action, reload, append_output) do
case Posts.get_post(post_id) do
nil ->
@@ -125,7 +137,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
active_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
draft = current_draft(socket.assigns, post, metadata, active_language)
case persist(post, draft, active_language, metadata, action) do
@@ -135,9 +150,30 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket
|> assign(:workbench, workbench)
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, normalized_form))
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, save_state_for_action(action)))
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: record_title(record, Posts.get_post!(post_id)), subtitle: Atom.to_string(record_status(record))}))
|> assign(
:post_editor_drafts,
put_nested_map(
socket.assigns.post_editor_drafts,
post_id,
active_language,
normalized_form
)
)
|> assign(
:post_editor_save_states,
Map.put(
socket.assigns.post_editor_save_states,
post_id,
save_state_for_action(action)
)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:post, post_id}, %{
title: record_title(record, Posts.get_post!(post_id)),
subtitle: Atom.to_string(record_status(record))
})
)
|> reload.(workbench)
{:error, reason} ->
@@ -148,6 +184,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec discard_socket(term(), term(), term(), term()) :: term()
def discard_socket(socket, post_id, reload, append_output) do
case Posts.get_post(post_id) do
nil ->
@@ -156,7 +193,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
active_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
case discard(post, active_language, metadata) do
{:ok, restored_post} ->
@@ -164,9 +203,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket
|> assign(:workbench, workbench)
|> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, active_language))
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :discarded))
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: restored_post.title || restored_post.slug || restored_post.id, subtitle: Atom.to_string(restored_post.status || :draft)}))
|> assign(
:post_editor_drafts,
delete_nested_map(socket.assigns.post_editor_drafts, post_id, active_language)
)
|> assign(
:post_editor_save_states,
Map.put(socket.assigns.post_editor_save_states, post_id, :discarded)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:post, post_id}, %{
title: restored_post.title || restored_post.slug || restored_post.id,
subtitle: Atom.to_string(restored_post.status || :draft)
})
)
|> reload.(workbench)
{:error, reason} ->
@@ -177,6 +228,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec delete_socket(term(), term(), term(), term()) :: term()
def delete_socket(socket, post_id, reload, append_output) do
case Posts.delete_post(post_id) do
{:ok, :deleted} ->
@@ -185,13 +237,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id}))
|> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id))
|> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id))
|> assign(:post_editor_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id))
|> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id))
|> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, post_id))
|> assign(
:post_editor_active_languages,
Map.delete(socket.assigns.post_editor_active_languages, post_id)
)
|> assign(
:post_editor_tag_queries,
Map.delete(socket.assigns.post_editor_tag_queries, post_id)
)
|> assign(
:post_editor_category_queries,
Map.delete(socket.assigns.post_editor_category_queries, post_id)
)
|> assign(
:post_editor_quick_actions_open,
Map.delete(socket.assigns.post_editor_quick_actions_open, post_id)
)
|> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id))
|> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id))
|> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id))
|> assign(
:post_editor_save_states,
Map.delete(socket.assigns.post_editor_save_states, post_id)
)
|> reload.(workbench)
{:error, reason} ->
@@ -201,6 +268,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec set_mode(term(), term(), term(), term()) :: term()
def set_mode(socket, post_id, mode, reload) do
workbench = socket.assigns.workbench
normalized_mode = normalize_mode(mode)
@@ -216,38 +284,67 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
socket
|> assign(:post_editor_modes, Map.put(socket.assigns.post_editor_modes, post_id, normalized_mode))
|> assign(
:post_editor_modes,
Map.put(socket.assigns.post_editor_modes, post_id, normalized_mode)
)
|> reload.(workbench)
end
@spec toggle_section(term(), term(), term(), term()) :: term()
def toggle_section(socket, post_id, section, reload) when section in [:metadata, :excerpt] do
workbench = socket.assigns.workbench
socket
|> assign(:post_editor_expanded, Map.put(socket.assigns.post_editor_expanded, post_id, toggled_sections(socket.assigns.post_editor_expanded, post_id, section)))
|> assign(
:post_editor_expanded,
Map.put(
socket.assigns.post_editor_expanded,
post_id,
toggled_sections(socket.assigns.post_editor_expanded, post_id, section)
)
)
|> reload.(workbench)
end
@spec select_language(term(), term(), term(), term()) :: term()
def select_language(socket, post_id, language, reload) do
workbench = socket.assigns.workbench
socket
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, normalize_language(language, language)))
|> assign(
:post_editor_active_languages,
Map.put(
socket.assigns.post_editor_active_languages,
post_id,
normalize_language(language, language)
)
)
|> reload.(workbench)
end
@spec toggle_quick_actions(term(), term(), term()) :: term()
def toggle_quick_actions(socket, post_id, reload) do
workbench = socket.assigns.workbench
socket
|> assign(:post_editor_quick_actions_open, Map.update(socket.assigns.post_editor_quick_actions_open, post_id, true, &(!&1)))
|> assign(
:post_editor_quick_actions_open,
Map.update(socket.assigns.post_editor_quick_actions_open, post_id, true, &(!&1))
)
|> reload.(workbench)
end
@spec detect_language(term(), term(), term(), term()) :: term()
def detect_language(socket, post_id, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do
socket
|> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|> append_output.(
translated("Detect Language"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)
|> reload.(socket.assigns.workbench)
else
case Posts.get_post(post_id) do
@@ -257,14 +354,24 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
active_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
draft = current_draft(socket.assigns, post, metadata, active_language)
text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "content", "")], "\n\n")
case AI.detect_language(text) do
{:ok, %{language_code: language_code}} when is_binary(language_code) and language_code != "" ->
{:ok, %{language_code: language_code}}
when is_binary(language_code) and language_code != "" ->
socket
|> put_draft_field(post_id, post, active_language, "language", normalize_language(language_code, canonical_language))
|> put_draft_field(
post_id,
post,
active_language,
"language",
normalize_language(language_code, canonical_language)
)
|> reload_with_assigned_workbench(reload)
{:error, reason} ->
@@ -274,17 +381,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
_other ->
socket
|> append_output.(translated("Detect Language"), translated("Language detection failed."), nil, "error")
|> append_output.(
translated("Detect Language"),
translated("Language detection failed."),
nil,
"error"
)
|> reload.(socket.assigns.workbench)
end
end
end
end
@spec translate(term(), term(), term(), term(), term()) :: term()
def translate(socket, post_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do
socket
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|> append_output.(
translated("Translate"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)
|> reload.(socket.assigns.workbench)
else
normalized_language = normalize_language(language, "")
@@ -298,9 +416,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
content: translation.content
}) do
socket
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, normalized_language))
|> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, normalized_language))
|> assign(:post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false))
|> assign(
:post_editor_active_languages,
Map.put(socket.assigns.post_editor_active_languages, post_id, normalized_language)
)
|> assign(
:post_editor_drafts,
delete_nested_map(socket.assigns.post_editor_drafts, post_id, normalized_language)
)
|> assign(
:post_editor_quick_actions_open,
Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false)
)
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
@@ -317,6 +444,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec apply_ai_suggestions(term(), term(), term(), term(), term()) :: term()
def apply_ai_suggestions(socket, post_id, fields, reload, append_output) do
case Posts.get_post(post_id) do
nil ->
@@ -340,12 +468,30 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
case Posts.update_post(post_id, attrs) do
{:ok, updated_post} ->
metadata = project_metadata(updated_post.project_id)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language(updated_post, metadata))
active_language =
Map.get(
socket.assigns.post_editor_active_languages,
post_id,
canonical_language(updated_post, metadata)
)
refreshed_form = persisted_form(updated_post, metadata, active_language)
socket
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, refreshed_form))
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty))
|> assign(
:post_editor_drafts,
put_nested_map(
socket.assigns.post_editor_drafts,
post_id,
active_language,
refreshed_form
)
)
|> assign(
:post_editor_save_states,
Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)
)
|> assign(:shell_overlay, nil)
|> reload.(socket.assigns.workbench)
@@ -358,6 +504,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec insert_content(term(), term(), term(), term()) :: term()
def insert_content(socket, post_id, snippet, reload) do
socket
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{id: post_id, content: snippet})
@@ -365,6 +512,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|> reload.(socket.assigns.workbench)
end
@spec add_list_value(term(), term(), term(), term(), term()) :: term()
def add_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do
case Posts.get_post(post_id) do
nil ->
@@ -373,7 +521,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
active_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
draft = current_draft(socket.assigns, post, metadata, active_language)
normalized = normalize_list_entry(value)
@@ -398,6 +549,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec remove_list_value(term(), term(), term(), term(), term()) :: term()
def remove_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do
case Posts.get_post(post_id) do
nil ->
@@ -406,9 +558,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
active_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
draft = current_draft(socket.assigns, post, metadata, active_language)
updated = draft |> Map.get(field_key(kind), "") |> csv_to_list() |> Enum.reject(&(&1 == value)) |> Enum.join(", ")
updated =
draft
|> Map.get(field_key(kind), "")
|> csv_to_list()
|> Enum.reject(&(&1 == value))
|> Enum.join(", ")
socket
|> put_draft_field(post_id, post, active_language, field_key(kind), updated)
@@ -416,6 +577,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do
case Posts.get_post(post_id) do
nil ->
@@ -424,7 +586,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = assigned_project_metadata(assigns)
canonical_language = canonical_language(post, metadata)
active_language = Map.get(assigns.post_editor_active_languages, post.id, canonical_language)
active_language =
Map.get(assigns.post_editor_active_languages, post.id, canonical_language)
translations = translations(post.id)
persisted = DraftManagement.persisted_form(post, metadata, active_language, translations)
@@ -453,13 +618,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
metadata_expanded: Map.get(expanded, :metadata, false),
excerpt_expanded: Map.get(expanded, :excerpt, false),
mode: Map.get(assigns.post_editor_modes, post.id, :markdown),
editing_canonical?: editing_canonical_language?(translations, active_language, canonical_language),
editing_canonical?:
editing_canonical_language?(translations, active_language, canonical_language),
can_publish?: post.status == :draft,
can_delete?: post.status == :published,
has_published_version?: has_published_version?(post),
discard_label: discard_label(post),
discard_title: discard_title(post),
detect_language_enabled?: not blank?(Map.get(form, "title")) or not blank?(Map.get(form, "content")),
detect_language_enabled?:
not blank?(Map.get(form, "title")) or not blank?(Map.get(form, "content")),
can_translate?: Enum.any?(languages(metadata), &(&1 != canonical_language)),
languages: languages(metadata),
form: form,
@@ -469,16 +636,45 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
tag_values: tag_values(form),
tag_chips: tag_chips(form, Tags.list_tags(post.project_id)),
tag_query: query_value(assigns, :tags, post.id),
tag_query_addable?: query_addable?(query_value(assigns, :tags, post.id), tag_values(form), Tags.list_tags(post.project_id), fn option -> option.name end),
tag_query_addable?:
query_addable?(
query_value(assigns, :tags, post.id),
tag_values(form),
Tags.list_tags(post.project_id),
fn option -> option.name end
),
category_values: category_values(form),
category_query: query_value(assigns, :categories, post.id),
category_options: metadata.categories || [],
category_query_addable?: query_addable?(query_value(assigns, :categories, post.id), category_values(form), metadata.categories || [], & &1),
tag_suggestions: tag_suggestions(form, Tags.list_tags(post.project_id), query_value(assigns, :tags, post.id)),
category_suggestions: category_suggestions(form, metadata.categories || [], query_value(assigns, :categories, post.id)),
category_query_addable?:
query_addable?(
query_value(assigns, :categories, post.id),
category_values(form),
metadata.categories || [],
& &1
),
tag_suggestions:
tag_suggestions(
form,
Tags.list_tags(post.project_id),
query_value(assigns, :tags, post.id)
),
category_suggestions:
category_suggestions(
form,
metadata.categories || [],
query_value(assigns, :categories, post.id)
),
gallery_count: gallery_count(form),
preview_url: preview_url(post, active_language, canonical_language, Map.get(assigns.post_editor_modes, post.id, :markdown)),
translation_flags: translation_flags(post, canonical_language, active_language, translations),
preview_url:
preview_url(
post,
active_language,
canonical_language,
Map.get(assigns.post_editor_modes, post.id, :markdown)
),
translation_flags:
translation_flags(post, canonical_language, active_language, translations),
linked_media: linked_media(post.id),
post_links: post_links(post.id),
footer: footer(post, current_translation, active_language, canonical_language)
@@ -488,17 +684,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
def build(_assigns), do: nil
@spec post_status_label(term()) :: term()
def post_status_label(status), do: ShellData.dashboard_status_label(status)
@spec post_editor_save_state_label(term()) :: term()
def post_editor_save_state_label(:dirty), do: translated("Unsaved")
def post_editor_save_state_label(:saved), do: translated("Saved")
def post_editor_save_state_label(:published), do: translated("Published")
def post_editor_save_state_label(:discarded), do: translated("Reverted")
def post_editor_save_state_label(_state), do: translated("Idle")
@spec post_editor_mode_label(term()) :: term()
def post_editor_mode_label(:markdown), do: translated("Markdown")
def post_editor_mode_label(:preview), do: translated("Preview")
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())

View File

@@ -8,11 +8,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
alias BDS.Desktop.ShellLive.PostEditor.PostMetadata
alias BDS.UI.Workbench
@spec normalize_mode(term()) :: term()
def normalize_mode(mode) when mode in [:markdown, :preview], do: mode
@spec normalize_mode(term()) :: term()
def normalize_mode("visual"), do: :markdown
def normalize_mode("preview"), do: :preview
def normalize_mode(_mode), do: :markdown
@spec normalize_language(term(), term()) :: term()
def normalize_language(value, fallback) do
case value |> to_string() |> String.trim() do
"" -> fallback
@@ -20,6 +23,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
end
end
@spec normalize_params(term(), term(), term()) :: term()
def normalize_params(params, current_language, next_language) do
%{
"title" => Map.get(params, "title", ""),
@@ -28,12 +32,17 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
"tags" => Map.get(params, "tags", ""),
"categories" => Map.get(params, "categories", ""),
"author" => Map.get(params, "author", ""),
"language" => if(current_language == next_language, do: normalize_language(Map.get(params, "language"), current_language), else: next_language),
"language" =>
if(current_language == next_language,
do: normalize_language(Map.get(params, "language"), current_language),
else: next_language
),
"do_not_translate" => truthy?(Map.get(params, "do_not_translate")),
"template_slug" => Map.get(params, "template_slug", "")
}
end
@spec current_draft(term(), term(), term(), term()) :: term()
def current_draft(assigns, %Post{} = post, metadata, active_language) do
persisted = persisted_form(post, metadata, active_language)
@@ -42,10 +51,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
|> Map.get(active_language, persisted)
end
@spec persisted_form(term(), term(), term()) :: term()
def persisted_form(%Post{} = post, metadata, active_language) do
persisted_form(post, metadata, active_language, PostMetadata.translations(post.id))
end
@spec persisted_form(term(), term(), term(), term()) :: term()
def persisted_form(post, metadata, active_language, translations) do
canonical_language = PostMetadata.canonical_language(post, metadata)
translation = Map.get(translations, active_language)
@@ -64,8 +75,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
}
else
%{
"title" => translation && translation.title || "",
"excerpt" => translation && translation.excerpt || "",
"title" => (translation && translation.title) || "",
"excerpt" => (translation && translation.excerpt) || "",
"content" => if(translation, do: Posts.editor_body(translation), else: ""),
"tags" => Enum.join(post.tags || [], ", "),
"categories" => Enum.join(post.categories || [], ", "),
@@ -77,22 +88,43 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
end
end
@spec maybe_update_draft(term(), term(), term(), term(), term(), term(), term()) :: term()
def maybe_update_draft(socket, post_id, post, current_language, next_language, draft, true) do
workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
socket
|> assign(:workbench, workbench)
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft))
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language))
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty))
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: draft["title"], subtitle: Atom.to_string(post.status || :draft)}))
|> assign(
:post_editor_drafts,
put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)
)
|> assign(
:post_editor_active_languages,
Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)
)
|> assign(
:post_editor_save_states,
Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:post, post_id}, %{
title: draft["title"],
subtitle: Atom.to_string(post.status || :draft)
})
)
|> maybe_drop_old_language_draft(post_id, current_language, next_language)
end
def maybe_update_draft(socket, post_id, _post, _current_language, next_language, _draft, false) do
assign(socket, :post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language))
assign(
socket,
:post_editor_active_languages,
Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)
)
end
@spec put_draft_field(term(), term(), term(), term(), term(), term()) :: term()
def put_draft_field(socket, post_id, post, active_language, field, value) do
metadata = PostMetadata.project_metadata(post.project_id)
draft = Map.put(current_draft(socket.assigns, post, metadata, active_language), field, value)
@@ -100,15 +132,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
socket
|> assign(:workbench, workbench)
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, draft))
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty))
|> assign(
:post_editor_drafts,
put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, draft)
)
|> assign(
:post_editor_save_states,
Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)
)
end
@spec put_query_state(term(), term(), term(), term()) :: term()
def put_query_state(socket, post_id, kind, value) do
key = query_key(kind)
assign(socket, key, Map.put(Map.get(socket.assigns, key, %{}), post_id, to_string(value || "")))
assign(
socket,
key,
Map.put(Map.get(socket.assigns, key, %{}), post_id, to_string(value || ""))
)
end
@spec query_value(term(), term(), term()) :: term()
def query_value(assigns, kind, post_id) do
assigns
|> Map.get(query_key(kind), %{})
@@ -118,25 +163,33 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
defp query_key(:tags), do: :post_editor_tag_queries
defp query_key(:categories), do: :post_editor_category_queries
defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) when current_language == next_language,
do: socket
defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language)
when current_language == next_language,
do: socket
defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do
assign(socket, :post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language))
assign(
socket,
:post_editor_drafts,
delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language)
)
end
@spec toggled_sections(term(), term(), term()) :: term()
def toggled_sections(expanded_by_post, post_id, section) do
expanded_by_post
|> Map.get(post_id, %{metadata: false, excerpt: false})
|> Map.put_new(:metadata, false)
|> Map.put_new(:excerpt, false)
|> Map.update!(section, &not &1)
|> Map.update!(section, &(not &1))
end
@spec put_nested_map(term(), term(), term(), term()) :: term()
def put_nested_map(map, key, nested_key, value) do
Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value))
end
@spec delete_nested_map(term(), term(), term()) :: term()
def delete_nested_map(map, key, nested_key) do
case Map.get(map, key) do
nil ->
@@ -150,20 +203,26 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
end
end
def reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench)
@spec reload_with_assigned_workbench(term(), term()) :: term()
def reload_with_assigned_workbench(socket, reload),
do: reload.(socket, socket.assigns.workbench)
@spec save_state_for_action(term()) :: term()
def save_state_for_action(:publish), do: :published
def save_state_for_action(_action), do: :saved
@spec record_title(term(), term()) :: term()
def record_title(%Translation{title: title}, post),
do: blank_to_nil(title) || post.title || post.slug || post.id
def record_title(%Post{title: title, slug: slug, id: id}, _post),
do: blank_to_nil(title) || blank_to_nil(slug) || id
@spec record_status(term()) :: term()
def record_status(%Translation{status: status}), do: status || :draft
def record_status(%Post{status: status}), do: status || :draft
@spec editing_canonical_language?(term(), term(), term()) :: term()
def editing_canonical_language?(translations, active_language, canonical_language) do
active_language == canonical_language or not Map.has_key?(translations, active_language)
end

View File

@@ -3,17 +3,22 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
alias BDS.{Metadata, Tags}
@spec field_key(term()) :: term()
def field_key(:tags), do: "tags"
def field_key(:categories), do: "categories"
@spec tag_values(term()) :: term()
def tag_values(form), do: csv_to_list(Map.get(form, "tags", ""))
@spec category_values(term()) :: term()
def category_values(form), do: csv_to_list(Map.get(form, "categories", ""))
@spec tag_suggestions(term(), term(), term()) :: term()
def tag_suggestions(form, options, query) do
selected = MapSet.new(tag_values(form))
filter_suggestions(options, query, fn option -> option.name end, selected)
end
@spec tag_chips(term(), term()) :: term()
def tag_chips(form, options) do
option_map = Map.new(options, fn option -> {option.name, option} end)
@@ -23,6 +28,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
end)
end
@spec category_suggestions(term(), term(), term()) :: term()
def category_suggestions(form, options, query) do
selected = MapSet.new(category_values(form))
filter_suggestions(options, query, & &1, selected)
@@ -34,11 +40,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
options
|> Enum.filter(fn option ->
label = labeler.(option)
not MapSet.member?(selected, label) and (query == "" or String.contains?(String.downcase(label), query))
not MapSet.member?(selected, label) and
(query == "" or String.contains?(String.downcase(label), query))
end)
|> Enum.take(8)
end
@spec query_addable?(term(), term(), term(), term()) :: term()
def query_addable?(query, selected_values, options, labeler) do
normalized = normalize_query(query)
@@ -54,6 +63,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|> String.downcase()
end
@spec normalize_list_entry(term()) :: term()
def normalize_list_entry(value) do
value
|> to_string()
@@ -61,6 +71,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|> String.downcase()
end
@spec ensure_list_value(term(), term(), term()) :: term()
def ensure_list_value(project_id, :tags, value) do
if Enum.any?(Tags.list_tags(project_id), &(String.downcase(&1.name) == value)) do
:ok
@@ -83,6 +94,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
_error -> :ok
end
@spec csv_to_list(term()) :: term()
def csv_to_list(value) do
value
|> to_string()
@@ -91,6 +103,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|> Enum.reject(&(&1 == ""))
end
@spec tag_chip_style(term()) :: term()
def tag_chip_style(nil), do: nil
def tag_chip_style(color) do
@@ -121,5 +134,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
defp contrast_color(_color), do: "#ffffff"
@spec ai_overlay_fields(term()) :: term()
def ai_overlay_fields(selected), do: Enum.filter(selected, & &1.accepted)
end

View File

@@ -6,11 +6,16 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, PostMetadata}
@spec persist(term(), term(), term(), term(), term()) :: term()
def persist(%Post{} = post, draft, active_language, metadata, action) do
canonical_language = PostMetadata.canonical_language(post, metadata)
translations = PostMetadata.translations(post.id)
if DraftManagement.editing_canonical_language?(translations, active_language, canonical_language) do
if DraftManagement.editing_canonical_language?(
translations,
active_language,
canonical_language
) do
post
|> save_canonical_draft(draft)
|> maybe_publish_post(post.id, action)
@@ -21,12 +26,17 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
end
end
@spec discard(term(), term(), term()) :: term()
def discard(%Post{} = post, active_language, metadata) do
canonical_language = PostMetadata.canonical_language(post, metadata)
current_translations = PostMetadata.translations(post.id)
cond do
not DraftManagement.editing_canonical_language?(current_translations, active_language, canonical_language) ->
not DraftManagement.editing_canonical_language?(
current_translations,
active_language,
canonical_language
) ->
{:ok, post}
post.file_path not in [nil, ""] and post.status == :draft ->
@@ -37,15 +47,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
end
end
@spec has_published_version?(term()) :: term()
def has_published_version?(%Post{} = post),
do: not is_nil(post.published_at) or post.file_path not in [nil, ""]
@spec discard_label(term()) :: term()
def discard_label(%Post{} = post) do
if has_published_version?(post),
do: translated("Discard Changes"),
else: translated("Discard Draft")
end
@spec discard_title(term()) :: term()
def discard_title(%Post{} = post) do
if has_published_version?(post),
do: translated("Discard changes and restore the published version"),

View File

@@ -8,6 +8,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
alias BDS.Media.Media, as: MediaRecord
alias BDS.Posts.{Post, PostMedia}
@spec project_metadata(term()) :: term()
def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
def project_metadata(project_id) do
@@ -17,6 +18,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
_error -> %{main_language: "en", blog_languages: []}
end
@spec canonical_language(term(), term()) :: term()
def canonical_language(post, metadata) do
BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language(
post.language,
@@ -24,28 +26,36 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
)
end
@spec translations(term()) :: term()
def translations(post_id) do
{:ok, translations} = Posts.list_post_translations(post_id)
Map.new(translations, fn translation -> {translation.language, translation} end)
end
@spec languages(term()) :: term()
def languages(metadata) do
(([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) ++ Enum.map(I18n.supported_languages(), & &1.code))
(([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) ++
Enum.map(I18n.supported_languages(), & &1.code))
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
end
@spec template_options(term()) :: term()
def template_options(project_id) do
Repo.all(
from template in Templates.Template,
where: template.project_id == ^project_id,
order_by: [asc: template.title, asc: template.slug],
select: %{slug: template.slug, title: fragment("COALESCE(?, ?)", template.title, template.slug)}
select: %{
slug: template.slug,
title: fragment("COALESCE(?, ?)", template.title, template.slug)
}
)
rescue
_error -> []
end
@spec linked_media(term()) :: term()
def linked_media(post_id) do
rows =
Repo.all(
@@ -74,6 +84,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
_error -> []
end
@spec post_links(term()) :: term()
def post_links(post_id) do
%{
backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id),
@@ -84,15 +95,29 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
defp related_posts(links, key) do
Enum.map(links, fn link ->
case Posts.get_post(Map.fetch!(link, key)) do
%Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id}
_other -> nil
%Post{} = post ->
%{
id: post.id,
title: post.title || post.slug || post.id,
text: link.link_text || post.slug || post.id
}
_other ->
nil
end
end)
|> Enum.reject(&is_nil/1)
end
@spec translation_flags(term(), term(), term(), term()) :: term()
def translation_flags(post, canonical_language, active_language, translations) do
canonical = %{language: canonical_language, flag: I18n.flag(canonical_language), status: Atom.to_string(post.status || :draft), active: active_language == canonical_language, label: canonical_language}
canonical = %{
language: canonical_language,
flag: I18n.flag(canonical_language),
status: Atom.to_string(post.status || :draft),
active: active_language == canonical_language,
label: canonical_language
}
others =
translations
@@ -111,6 +136,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
[canonical | others]
end
@spec footer(term(), term(), term(), term()) :: term()
def footer(post, translation, active_language, canonical_language) do
if active_language == canonical_language do
%{
@@ -120,8 +146,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
}
else
%{
created_at: format_timestamp(translation && translation.created_at || post.created_at),
updated_at: format_timestamp(translation && translation.updated_at || post.updated_at),
created_at: format_timestamp((translation && translation.created_at) || post.created_at),
updated_at: format_timestamp((translation && translation.updated_at) || post.updated_at),
published_at: format_timestamp(translation && translation.published_at)
}
end
@@ -135,10 +161,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> Calendar.strftime("%x")
end
@spec display_title(term(), term(), term()) :: term()
def display_title(title, slug, fallback_id) do
blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled")
end
@spec gallery_count(term()) :: term()
def gallery_count(form) do
form
|> Map.get("content", "")
@@ -147,8 +175,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> length()
end
def preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview, do: nil
@spec preview_url(term(), term(), term(), term()) :: term()
def preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview,
do: nil
@spec preview_url(term(), term(), term(), term()) :: term()
def preview_url(%Post{} = post, active_language, canonical_language, :preview) do
query =
%{}
@@ -156,7 +187,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> maybe_put_query("post_id", post.id)
|> maybe_put_query("lang", active_language != canonical_language && active_language)
Preview.base_url() <> canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query)
Preview.base_url() <>
canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query)
end
defp canonical_preview_path(created_at_ms, slug) do
@@ -171,10 +203,13 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
defp maybe_put_query(query, key, value), do: Map.put(query, key, value)
def truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false
@spec blank?(term()) :: term()
def blank?(value), do: blank_to_nil(value) == nil
@spec blank_to_nil(term()) :: term()
def blank_to_nil(value) do
value
|> to_string()

View File

@@ -19,7 +19,9 @@ defmodule BDS.Desktop.ShellLive.SessionUtil do
Stream.iterate(1, &(&1 + 1))
|> Enum.find_value(fn index ->
candidate =
if index == 1, do: @default_new_project_name, else: "#{@default_new_project_name} #{index}"
if index == 1,
do: @default_new_project_name,
else: "#{@default_new_project_name} #{index}"
if MapSet.member?(existing_names, candidate), do: nil, else: candidate
end)

View File

@@ -17,7 +17,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
alias BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings
alias BDS.Desktop.ShellLive.SettingsEditor.StyleEditor
embed_templates "settings_editor_html/*"
embed_templates("settings_editor_html/*")
@settings_sections ~w(project editor content ai technology publishing data mcp)
@supported_languages ["en", "de", "fr", "it", "es"]
@@ -45,6 +45,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
defdelegate theme_display_name(theme), to: StyleEditor
defdelegate protected_category?(category), to: ManagedCategories
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
case socket.assigns[:current_tab] do
%{type: :settings} ->
@@ -64,12 +65,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
end
end
@spec update_search(term(), term(), term()) :: term()
def update_search(socket, query, reload) do
socket
|> assign(:settings_editor_search, to_string(query || ""))
|> reload.(socket.assigns.workbench)
end
@spec build_settings(term()) :: term()
def build_settings(%{projects: %{active_project_id: nil}}), do: nil
def build_settings(assigns) do
@@ -82,7 +85,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
)
editor_form =
Map.merge(EditorSettings.editor_form(), Map.get(assigns, :settings_editor_editor_draft, %{}))
Map.merge(
EditorSettings.editor_form(),
Map.get(assigns, :settings_editor_editor_draft, %{})
)
ai_form =
Map.merge(AISettings.ai_form(assigns), Map.get(assigns, :settings_editor_ai_draft, %{}))
@@ -142,6 +148,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
}
end
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@@ -171,7 +178,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
Enum.filter(@settings_sections, fn section ->
case section do
"project" ->
section_matches?(query, ~w(project name description data url language author bookmarklet))
section_matches?(
query,
~w(project name description data url language author bookmarklet)
)
"editor" ->
section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged))
@@ -195,7 +205,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
section_matches?(query, ~w(data rebuild maintenance links thumbnails filesystem))
"mcp" ->
section_matches?(query, ~w(mcp claude copilot gemini opencode mistral codex agent server))
section_matches?(
query,
~w(mcp claude copilot gemini opencode mistral codex agent server)
)
end
end)
end

View File

@@ -7,6 +7,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
@spec ai_form(term()) :: term()
def ai_form(assigns) do
{:ok, online_endpoint} = AI.get_endpoint(:online)
{:ok, airplane_endpoint} = AI.get_endpoint(:airplane)
@@ -30,18 +31,21 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
}
end
@spec endpoint_model_options(term(), term()) :: term()
def endpoint_model_options(assigns, endpoint_key) do
assigns
|> Map.get(:settings_editor_endpoint_models, %{})
|> Map.get(endpoint_key, [])
end
@spec update_ai_draft(term(), term(), term()) :: term()
def update_ai_draft(socket, params, reload) do
socket
|> assign(:settings_editor_ai_draft, normalize_ai_params(params))
|> reload.(socket.assigns.workbench)
end
@spec refresh_ai_models(term(), term(), term(), term()) :: term()
def refresh_ai_models(socket, endpoint_key, reload, append_output) do
attrs = ai_attrs(socket.assigns)
@@ -65,11 +69,17 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
end
end
@spec save_ai(term(), term(), term()) :: term()
def save_ai(socket, reload, append_output) do
attrs = ai_attrs(socket.assigns)
with :ok <-
put_endpoint_preferences(:online, attrs.online_url, attrs.online_api_key, attrs.online_chat_model),
put_endpoint_preferences(
:online,
attrs.online_url,
attrs.online_api_key,
attrs.online_chat_model
),
:ok <-
put_endpoint_preferences(
:airplane,
@@ -85,7 +95,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
:ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model),
:ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model),
:ok <-
maybe_put_model_preference(:airplane_image_analysis, attrs.offline_image_analysis_model),
maybe_put_model_preference(
:airplane_image_analysis,
attrs.offline_image_analysis_model
),
:ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do
socket
|> assign(:settings_editor_ai_draft, %{})
@@ -99,6 +112,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
end
end
@spec reset_ai_prompt(term(), term(), term()) :: term()
def reset_ai_prompt(socket, reload, append_output) do
case EditorSettings.put_global_setting("ai.system_prompt", "") do
:ok ->

View File

@@ -6,21 +6,25 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.EditorSettings do
alias BDS.Settings
alias BDS.Desktop.ShellData
@spec editor_form() :: term()
def editor_form do
%{
"default_mode" => get_global_setting("ui.preferred_editor_mode") || "markdown",
"diff_view_style" => get_global_setting("ui.git_diff_view_style") || "inline",
"wrap_long_lines" => get_global_setting("ui.git_diff_word_wrap") == "true",
"hide_unchanged_regions" => get_global_setting("ui.git_diff_hide_unchanged_regions") == "true"
"hide_unchanged_regions" =>
get_global_setting("ui.git_diff_hide_unchanged_regions") == "true"
}
end
@spec update_editor_draft(term(), term(), term()) :: term()
def update_editor_draft(socket, params, reload) do
socket
|> assign(:settings_editor_editor_draft, normalize_editor_params(params))
|> reload.(socket.assigns.workbench)
end
@spec save_editor(term(), term(), term()) :: term()
def save_editor(socket, reload, append_output) do
attrs = editor_attrs(socket.assigns)
@@ -43,10 +47,12 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.EditorSettings do
end
end
@spec get_global_setting(term()) :: term()
def get_global_setting(key) do
Settings.get_global_setting(key)
end
@spec put_global_setting(term(), term()) :: term()
def put_global_setting(key, value) do
Settings.put_global_setting(key, value)
end

View File

@@ -14,10 +14,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
"page" => %{title: "page", render_in_lists: false, show_title: true}
}
@spec protected_categories() :: term()
def protected_categories, do: @protected_categories
@spec protected_category?(term()) :: term()
def protected_category?(category), do: MapSet.member?(@protected_categories, category)
@spec category_rows(term()) :: term()
def category_rows(metadata) do
categories = Map.get(metadata, :categories, [])
settings = Map.get(metadata, :category_settings, %{})
@@ -37,12 +40,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end)
end
@spec update_new_category(term(), term(), term()) :: term()
def update_new_category(socket, name, reload) do
socket
|> assign(:settings_editor_new_category, to_string(name || ""))
|> reload.(socket.assigns.workbench)
end
@spec add_category(term(), term(), term()) :: term()
def add_category(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
name = socket.assigns[:settings_editor_new_category] |> to_string() |> String.trim()
@@ -73,11 +78,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end
end
@spec reset_categories(term(), term(), term()) :: term()
def reset_categories(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
result =
Enum.reduce_while(category_names(project_metadata(socket.assigns)), :ok, fn category, _acc ->
Enum.reduce_while(category_names(project_metadata(socket.assigns)), :ok, fn category,
_acc ->
if MapSet.member?(@protected_categories, category) do
{:cont, :ok}
else
@@ -102,6 +109,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end
end
@spec save_category(term(), term(), term(), term()) :: term()
def save_category(socket, params, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
category = Map.get(params, "category", "")
@@ -125,6 +133,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end
end
@spec remove_category(term(), term(), term(), term()) :: term()
def remove_category(socket, category, reload, append_output) do
project_id = socket.assigns.projects.active_project_id

View File

@@ -16,6 +16,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
%{id: :openai_codex, label: "OpenAI Codex", supported?: false}
]
@spec mcp_rows() :: term()
def mcp_rows do
Enum.map(@mcp_agents, fn agent ->
%{
@@ -28,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
end)
end
@spec toggle_mcp_agent(term(), term(), term(), term()) :: term()
def toggle_mcp_agent(socket, agent, reload, append_output) do
case find_mcp_agent(agent) do
%{id: agent_id, supported?: true} = config ->

View File

@@ -6,12 +6,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
alias BDS.Metadata
alias BDS.Desktop.ShellData
@spec project_metadata(term()) :: term()
def project_metadata(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} -> metadata
end
end
@spec project_form(term()) :: term()
def project_form(metadata) do
%{
"name" => Map.get(metadata, :name, ""),
@@ -28,18 +30,21 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
}
end
@spec technology_form(term()) :: term()
def technology_form(project_form) do
%{
"semantic_similarity_enabled" => Map.get(project_form, "semantic_similarity_enabled", false)
}
end
@spec update_project_draft(term(), term(), term()) :: term()
def update_project_draft(socket, params, reload) do
socket
|> assign(:settings_editor_project_draft, normalize_project_params(params))
|> reload.(socket.assigns.workbench)
end
@spec save_project(term(), term(), term()) :: term()
def save_project(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
alias BDS.Metadata
alias BDS.Desktop.ShellData
@spec publishing_form(term()) :: term()
def publishing_form(metadata) do
prefs = Map.get(metadata, :publishing_preferences, %{})
@@ -17,12 +18,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
}
end
@spec update_publishing_draft(term(), term(), term()) :: term()
def update_publishing_draft(socket, params, reload) do
socket
|> assign(:settings_editor_publishing_draft, normalize_publishing_params(params))
|> reload.(socket.assigns.workbench)
end
@spec save_publishing(term(), term(), term()) :: term()
def save_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
@@ -39,6 +42,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
end
end
@spec clear_publishing(term(), term(), term()) :: term()
def clear_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id

View File

@@ -29,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
"zinc"
]
@spec build_style(term()) :: term()
def build_style(%{projects: %{active_project_id: nil}}), do: nil
def build_style(assigns) do
@@ -40,22 +41,26 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
selected_theme: selected_theme,
applied_theme: current_theme(assigns),
preview_mode: preview_mode,
preview_url: "http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}"
preview_url:
"http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}"
}
end
@spec select_style_theme(term(), term(), term()) :: term()
def select_style_theme(socket, theme, reload) do
socket
|> assign(:style_editor_theme, to_string(theme || "default"))
|> reload.(socket.assigns.workbench)
end
@spec change_style_preview_mode(term(), term(), term()) :: term()
def change_style_preview_mode(socket, mode, reload) do
socket
|> assign(:style_editor_preview_mode, to_string(mode || "auto"))
|> reload.(socket.assigns.workbench)
end
@spec apply_style_theme(term(), term(), term()) :: term()
def apply_style_theme(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
theme = socket.assigns[:style_editor_theme] || current_theme(socket.assigns)
@@ -71,6 +76,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
end
end
@spec theme_display_name(term()) :: term()
def theme_display_name(theme) do
theme
|> to_string()
@@ -78,6 +84,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
|> String.capitalize()
end
@spec current_theme(term()) :: term()
def current_theme(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} ->

View File

@@ -22,7 +22,13 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
end
def create(socket, project_id, "post", callbacks) do
case BDS.Posts.create_post(%{project_id: project_id, title: "", content: "", tags: [], categories: []}) do
case BDS.Posts.create_post(%{
project_id: project_id,
title: "",
content: "",
tags: [],
categories: []
}) do
{:ok, _post} ->
callbacks.reload.(socket, socket.assigns.workbench)
@@ -42,7 +48,12 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:error, reason} ->
socket
|> callbacks.append_output.(translated("sidebar.importMedia"), inspect(reason), nil, "error")
|> callbacks.append_output.(
translated("sidebar.importMedia"),
inspect(reason),
nil,
"error"
)
|> callbacks.reload.(socket.assigns.workbench)
end
@@ -68,13 +79,23 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:ok, script} ->
callbacks.open_sidebar.(
socket,
%{"route" => "scripts", "id" => script.id, "title" => script.title, "subtitle" => "Automation helpers"},
%{
"route" => "scripts",
"id" => script.id,
"title" => script.title,
"subtitle" => "Automation helpers"
},
:pin
)
{:error, reason} ->
socket
|> callbacks.append_output.(translated("sidebar.scripts.newScript"), inspect(reason), nil, "error")
|> callbacks.append_output.(
translated("sidebar.scripts.newScript"),
inspect(reason),
nil,
"error"
)
|> callbacks.reload.(socket.assigns.workbench)
end
end
@@ -90,29 +111,52 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:ok, template} ->
callbacks.open_sidebar.(
socket,
%{"route" => "templates", "id" => template.id, "title" => template.title, "subtitle" => "Site rendering"},
%{
"route" => "templates",
"id" => template.id,
"title" => template.title,
"subtitle" => "Site rendering"
},
:pin
)
{:error, reason} ->
socket
|> callbacks.append_output.(translated("sidebar.templates.newTemplate"), inspect(reason), nil, "error")
|> callbacks.append_output.(
translated("sidebar.templates.newTemplate"),
inspect(reason),
nil,
"error"
)
|> callbacks.reload.(socket.assigns.workbench)
end
end
def create(socket, project_id, "import", callbacks) do
case ImportDefinitions.create_definition(%{project_id: project_id, name: translated("sidebar.import.newDefinition")}) do
case ImportDefinitions.create_definition(%{
project_id: project_id,
name: translated("sidebar.import.newDefinition")
}) do
{:ok, definition} ->
callbacks.open_sidebar.(
socket,
%{"route" => "import", "id" => definition.id, "title" => definition.name, "subtitle" => "Import definitions"},
%{
"route" => "import",
"id" => definition.id,
"title" => definition.name,
"subtitle" => "Import definitions"
},
:pin
)
{:error, reason} ->
socket
|> callbacks.append_output.(translated("sidebar.import.newDefinition"), inspect(reason), nil, "error")
|> callbacks.append_output.(
translated("sidebar.import.newDefinition"),
inspect(reason),
nil,
"error"
)
|> callbacks.reload.(socket.assigns.workbench)
end
end

View File

@@ -7,13 +7,17 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
if is_map(filters) and Map.get(filters, :enabled) do
panel_state = filter_panel_state(socket, view_id)
Map.put(sidebar_data, :filters, Map.merge(filters, %{
filter_panel_visible: panel_state.visible,
archive_collapsed: panel_state.archive_collapsed,
tags_collapsed: panel_state.tags_collapsed,
categories_collapsed: panel_state.categories_collapsed,
expanded_year: panel_state.expanded_year
}))
Map.put(
sidebar_data,
:filters,
Map.merge(filters, %{
filter_panel_visible: panel_state.visible,
archive_collapsed: panel_state.archive_collapsed,
tags_collapsed: panel_state.tags_collapsed,
categories_collapsed: panel_state.categories_collapsed,
expanded_year: panel_state.expanded_year
})
)
else
sidebar_data
end
@@ -22,7 +26,12 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
def put_filter_panel_state(socket, updater) do
view_id = Atom.to_string(socket.assigns.workbench.active_view)
state = socket |> filter_panel_state(view_id) |> updater.()
Phoenix.Component.assign(socket, :sidebar_filter_panels, Map.put(socket.assigns.sidebar_filter_panels, view_id, state))
Phoenix.Component.assign(
socket,
:sidebar_filter_panels,
Map.put(socket.assigns.sidebar_filter_panels, view_id, state)
)
end
def current_filters(socket, view_id) do
@@ -33,8 +42,17 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
def put_filters(socket, updater) do
view_id = Atom.to_string(socket.assigns.workbench.active_view)
filters = current_filters(socket, view_id) |> updater.() |> normalize_filters(socket.assigns.sidebar_data)
Phoenix.Component.assign(socket, :sidebar_filters_by_view, Map.put(socket.assigns.sidebar_filters_by_view, view_id, filters))
filters =
current_filters(socket, view_id)
|> updater.()
|> normalize_filters(socket.assigns.sidebar_data)
Phoenix.Component.assign(
socket,
:sidebar_filters_by_view,
Map.put(socket.assigns.sidebar_filters_by_view, view_id, filters)
)
end
def toggle_filter_value(filters, key, value) do

View File

@@ -11,12 +11,14 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
alias BDS.Tags.Tag
alias BDS.Templates.Template
embed_templates "tags_editor_html/*"
embed_templates("tags_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assign(socket, :tags_editor, build(socket.assigns))
end
@spec toggle_selection(term(), term(), term()) :: term()
def toggle_selection(socket, tag_name, reload) do
selected = Map.get(socket.assigns, :tags_editor_selected, [])
@@ -33,6 +35,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench)
end
@spec update_new_tag(term(), term(), term()) :: term()
def update_new_tag(socket, params, reload) do
socket
|> assign(:tags_editor_new_tag, %{
@@ -42,11 +45,16 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench)
end
@spec create_tag(term(), term(), term()) :: term()
def create_tag(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
draft = Map.get(socket.assigns, :tags_editor_new_tag, %{})
case Tags.create_tag(%{project_id: project_id, name: Map.get(draft, "name"), color: blank_to_nil(Map.get(draft, "color"))}) do
case Tags.create_tag(%{
project_id: project_id,
name: Map.get(draft, "name"),
color: blank_to_nil(Map.get(draft, "color"))
}) do
{:ok, _tag} ->
socket
|> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""})
@@ -59,6 +67,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
end
@spec update_edit_tag(term(), term(), term()) :: term()
def update_edit_tag(socket, params, reload) do
socket
|> assign(:tags_editor_edit_draft, %{
@@ -69,16 +78,26 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench)
end
@spec save_tag(term(), term(), term()) :: term()
def save_tag(socket, reload, append_output) do
selected = Map.get(socket.assigns, :tags_editor_selected, [])
draft = Map.get(socket.assigns, :tags_editor_edit_draft, %{})
case selected do
[tag_name] ->
case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do
nil -> reload.(socket, socket.assigns.workbench)
case Repo.get_by(Tag,
project_id: socket.assigns.projects.active_project_id,
name: tag_name
) do
nil ->
reload.(socket, socket.assigns.workbench)
%Tag{} = tag ->
with {:ok, _updated_tag} <- Tags.update_tag(tag.id, %{color: blank_to_nil(Map.get(draft, "color")), post_template_slug: blank_to_nil(Map.get(draft, "post_template_slug"))}),
with {:ok, _updated_tag} <-
Tags.update_tag(tag.id, %{
color: blank_to_nil(Map.get(draft, "color")),
post_template_slug: blank_to_nil(Map.get(draft, "post_template_slug"))
}),
{:ok, renamed_tag} <- maybe_rename_tag(tag, Map.get(draft, "name", tag.name)) do
socket
|> assign(:tags_editor_selected, [renamed_tag.name])
@@ -92,15 +111,22 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
end
_other -> reload.(socket, socket.assigns.workbench)
_other ->
reload.(socket, socket.assigns.workbench)
end
end
@spec delete_selected(term(), term(), term()) :: term()
def delete_selected(socket, reload, append_output) do
case Map.get(socket.assigns, :tags_editor_selected, []) do
[tag_name] ->
case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do
nil -> reload.(socket, socket.assigns.workbench)
case Repo.get_by(Tag,
project_id: socket.assigns.projects.active_project_id,
name: tag_name
) do
nil ->
reload.(socket, socket.assigns.workbench)
%Tag{} = tag ->
case Tags.delete_tag(tag.id) do
{:ok, _deleted} ->
@@ -116,16 +142,19 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
end
_other -> reload.(socket, socket.assigns.workbench)
_other ->
reload.(socket, socket.assigns.workbench)
end
end
@spec update_merge_target(term(), term(), term()) :: term()
def update_merge_target(socket, target, reload) do
socket
|> assign(:tags_editor_merge_target, to_string(target || ""))
|> reload.(socket.assigns.workbench)
end
@spec merge_selected(term(), term(), term()) :: term()
def merge_selected(socket, reload, append_output) do
selected = Map.get(socket.assigns, :tags_editor_selected, [])
target_name = Map.get(socket.assigns, :tags_editor_merge_target, "")
@@ -136,12 +165,19 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
true ->
project_id = socket.assigns.projects.active_project_id
tags = Repo.all(from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^selected)
tags =
Repo.all(
from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^selected
)
target = Enum.find(tags, &(&1.name == target_name))
sources = Enum.reject(tags, &(&1.name == target_name))
case target do
nil -> reload.(socket, socket.assigns.workbench)
nil ->
reload.(socket, socket.assigns.workbench)
_target ->
case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do
{:ok, _merged} ->
@@ -160,23 +196,41 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
end
@spec sync(term(), term(), term()) :: term()
def sync(socket, reload, append_output) do
_ = append_output
:ok = Tags.sync_tags_json(socket.assigns.projects.active_project_id)
reload.(socket, socket.assigns.workbench)
end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :tags}} = assigns) do
project_id = assigns.projects.active_project_id
tags = Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name])
tags =
Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name])
counts = tag_counts(project_id)
selected = Map.get(assigns, :tags_editor_selected, [])
edit_tag = if length(selected) == 1, do: Enum.find(tags, &(&1.name == hd(selected))), else: nil
edit_tag =
if length(selected) == 1, do: Enum.find(tags, &(&1.name == hd(selected))), else: nil
edit_draft = Map.get(assigns, :tags_editor_edit_draft, edit_draft(edit_tag))
templates = Repo.all(from template in Template, where: template.project_id == ^project_id, order_by: [asc: template.title], select: %{slug: template.slug, title: template.title})
templates =
Repo.all(
from template in Template,
where: template.project_id == ^project_id,
order_by: [asc: template.title],
select: %{slug: template.slug, title: template.title}
)
%{
tags: Enum.map(tags, fn tag -> %{name: tag.name, color: tag.color, count: Map.get(counts, tag.name, 0)} end),
tags:
Enum.map(tags, fn tag ->
%{name: tag.name, color: tag.color, count: Map.get(counts, tag.name, 0)}
end),
selected: selected,
new_tag: Map.get(assigns, :tags_editor_new_tag, %{"name" => "", "color" => ""}),
edit_draft: edit_draft,
@@ -187,14 +241,18 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
def build(_assigns), do: nil
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec tag_font_size(term(), term()) :: term()
def tag_font_size(count, counts) do
max_count = Enum.max([1 | Enum.map(counts, & &1.count)])
ratio = if max_count <= 1, do: 0.0, else: (count - 1) / max(max_count - 1, 1)
Float.round(0.85 + (1.8 - 0.85) * ratio, 2)
end
@spec tag_style(term(), term()) :: term()
def tag_style(tag, counts) do
size = tag_font_size(tag.count, counts)
@@ -217,7 +275,13 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
defp maybe_seed_edit_draft(socket, _selected), do: assign(socket, :tags_editor_edit_draft, %{})
defp edit_draft(nil), do: %{}
defp edit_draft(%Tag{} = tag), do: %{"name" => tag.name, "color" => tag.color || "", "post_template_slug" => tag.post_template_slug || ""}
defp edit_draft(%Tag{} = tag),
do: %{
"name" => tag.name,
"color" => tag.color || "",
"post_template_slug" => tag.post_template_slug || ""
}
defp maybe_rename_tag(%Tag{} = tag, next_name) do
normalized = String.trim(to_string(next_name || tag.name))
@@ -237,6 +301,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do
case String.trim(to_string(value)) do
"" -> nil

View File

@@ -46,7 +46,10 @@ defmodule BDS.Desktop.ShellLive.TaskLocalization do
|> Map.put(:message, localize_task_message(Map.get(task, :message), locale))
|> Map.put(:group_name, localize_task_group(Map.get(task, :group_name), locale))
|> Map.put(:status_label, localize_task_status_label(task.status, locale))
|> Map.put(:progress_label, if(is_number(progress), do: progress_percent(progress), else: nil))
|> Map.put(
:progress_label,
if(is_number(progress), do: progress_percent(progress), else: nil)
)
end
defp localize_task_message(nil, _locale), do: nil

View File

@@ -41,7 +41,9 @@ defmodule BDS.Desktop.ShellLive.TitlebarMenu do
@spec active_group(map()) :: map() | nil
def active_group(assigns) do
Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end)
Enum.find(assigns.menu_groups || [], fn group ->
Atom.to_string(group.id) == assigns.titlebar_menu_group
end)
end
@spec active_items(map()) :: [map()]
@@ -90,7 +92,9 @@ defmodule BDS.Desktop.ShellLive.TitlebarMenu do
Handle a keydown event on an open titlebar menu. `invoke_fun` is called
with the action id (string) when the user activates an item.
"""
@spec handle_keydown(Phoenix.LiveView.Socket.t(), String.t(), (Phoenix.LiveView.Socket.t(), String.t() -> Phoenix.LiveView.Socket.t())) ::
@spec handle_keydown(Phoenix.LiveView.Socket.t(), String.t(), (Phoenix.LiveView.Socket.t(),
String.t() ->
Phoenix.LiveView.Socket.t())) ::
Phoenix.LiveView.Socket.t()
def handle_keydown(socket, key, invoke_fun) do
if socket.assigns.titlebar_menu_group do
@@ -114,7 +118,9 @@ defmodule BDS.Desktop.ShellLive.TitlebarMenu do
defp rotate_group(socket, offset) do
groups = socket.assigns.menu_groups || []
current_group = socket.assigns.titlebar_menu_group
current_index = Enum.find_index(groups, fn group -> Atom.to_string(group.id) == current_group end)
current_index =
Enum.find_index(groups, fn group -> Atom.to_string(group.id) == current_group end)
if is_nil(current_index) or groups == [] do
socket

View File

@@ -57,4 +57,4 @@ defmodule BDS.Embeddings.Backends.InApp do
Enum.map(vector, &(&1 / norm))
end
end
end
end

View File

@@ -117,7 +117,9 @@ defmodule BDS.Frontmatter do
defp take_block_scalar_lines([line | rest], lines) do
if String.starts_with?(line, @block_scalar_indent) do
take_block_scalar_lines(rest, [String.replace_prefix(line, @block_scalar_indent, "") | lines])
take_block_scalar_lines(rest, [
String.replace_prefix(line, @block_scalar_indent, "") | lines
])
else
{Enum.reverse(lines), [line | rest]}
end

View File

@@ -2,13 +2,16 @@ defmodule BDS.Generation do
@moduledoc false
import Ecto.Query
import BDS.Generation.Paths,
except: [post_output_path: 1, post_output_path: 2]
import BDS.Generation.Sitemap,
only: [
render: 1,
render_multi_language: 6
]
import BDS.Generation.Progress
import BDS.Generation.Outputs
import BDS.Generation.Data
@@ -89,7 +92,8 @@ defmodule BDS.Generation do
{:ok, validation_report()} | {:error, term()}
def validate_site(project_id, sections \\ @core_sections, opts \\ [])
def validate_site(project_id, sections, opts) when is_binary(project_id) and is_list(sections) and is_list(opts) do
def validate_site(project_id, sections, opts)
when is_binary(project_id) and is_list(sections) and is_list(opts) do
with {:ok, plan} <- plan_generation(project_id, sections) do
on_progress = callback(opts)
:ok = report_validation_progress(on_progress, 0.0, "Collecting sitemap URLs...")
@@ -104,9 +108,12 @@ defmodule BDS.Generation do
{:ok, generated_files_list} = list_generated_files(project_id)
generated_file_updated_at = generated_file_updated_at_map(generated_files_list)
additional_languages = additional_languages(plan)
published_route_posts = suppress_subtree_translation_variants(data.published_route_posts, additional_languages)
{sitemap_content, sitemap_to_write, additional_expected_paths, additional_post_timestamp_checks} =
published_route_posts =
suppress_subtree_translation_variants(data.published_route_posts, additional_languages)
{sitemap_content, sitemap_to_write, additional_expected_paths,
additional_post_timestamp_checks} =
build_validation_sitemap_artifacts(
plan,
data,
@@ -155,8 +162,8 @@ defmodule BDS.Generation do
@spec apply_validation(String.t(), [section()] | map()) :: {:ok, map()} | {:error, term()}
def apply_validation(project_id, sections) when is_binary(project_id) and is_list(sections) do
with {:ok, plan} <- plan_generation(project_id, sections),
{:ok, actual_files} <- disk_generated_files(project_id) do
with {:ok, plan} <- plan_generation(project_id, sections),
{:ok, actual_files} <- disk_generated_files(project_id) do
expected_outputs = build_outputs(plan)
expected_paths = MapSet.new(Enum.map(expected_outputs, &elem(&1, 0)))
project = Projects.get_project!(project_id)
@@ -190,7 +197,8 @@ defmodule BDS.Generation do
generated_files_on_disk
|> Map.keys()
|> Enum.filter(fn relative_path ->
path_section(relative_path) in plan.sections and not MapSet.member?(expected_paths, relative_path)
path_section(relative_path) in plan.sections and
not MapSet.member?(expected_paths, relative_path)
end)
|> Enum.each(fn relative_path ->
_ = File.rm(output_path(project, relative_path))
@@ -215,6 +223,7 @@ defmodule BDS.Generation do
expected_output_map = Map.new(expected_outputs)
project = Projects.get_project!(project_id)
published_posts = list_published_posts(project_id)
targeted_plan =
build_targeted_validation_plan(
plan_validation_paths(report_paths(report), additional_languages(plan)),
@@ -224,7 +233,12 @@ defmodule BDS.Generation do
outputs_to_render =
expected_outputs
|> Enum.filter(fn {relative_path, _content} ->
targeted_output?(relative_path, targeted_plan, plan.language, additional_languages(plan))
targeted_output?(
relative_path,
targeted_plan,
plan.language,
additional_languages(plan)
)
end)
Enum.each(outputs_to_render, fn {relative_path, content} ->
@@ -243,7 +257,10 @@ defmodule BDS.Generation do
{:ok,
%{
rendered_url_count: Enum.count(outputs_to_render, fn {relative_path, _content} -> route_html_path?(relative_path) end),
rendered_url_count:
Enum.count(outputs_to_render, fn {relative_path, _content} ->
route_html_path?(relative_path)
end),
deleted_url_count: deleted_url_count,
removed_empty_dir_count: removed_empty_dir_count
}}
@@ -257,15 +274,21 @@ defmodule BDS.Generation do
defdelegate post_output_path(post, language), to: Paths
@typedoc "Result returned by `write_generated_file/3,4`."
@type write_result :: %{relative_path: String.t(), content_hash: String.t(), written?: boolean()}
@type write_result :: %{
relative_path: String.t(),
content_hash: String.t(),
written?: boolean()
}
@spec write_generated_file(String.t(), String.t(), String.t()) :: {:ok, write_result()}
def write_generated_file(project_id, relative_path, content),
do: write_generated_file(project_id, relative_path, content, [])
@spec write_generated_file(String.t(), String.t(), String.t(), keyword()) :: {:ok, write_result()}
@spec write_generated_file(String.t(), String.t(), String.t(), keyword()) ::
{:ok, write_result()}
def write_generated_file(project_id, relative_path, content, opts)
when is_binary(project_id) and is_binary(relative_path) and is_binary(content) and is_list(opts) do
when is_binary(project_id) and is_binary(relative_path) and is_binary(content) and
is_list(opts) do
project = Projects.get_project!(project_id)
content_hash = sha256(content)
now = Persistence.now_ms()
@@ -331,8 +354,12 @@ defmodule BDS.Generation do
data = generation_data(plan)
published_translations = flattened_generation_translations(data.translations_by_post)
translations_by_post_language = translation_lookup_map(published_translations)
translatable_published_posts = Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
translatable_published_list_posts = Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
translatable_published_posts =
Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
translatable_published_list_posts =
Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
localized_posts_by_language =
additional_languages(plan)
@@ -421,7 +448,10 @@ defmodule BDS.Generation do
pagefind_outputs =
if :core in plan.sections do
BDS.Generation.Pagefind.build_outputs(plan, core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs)
BDS.Generation.Pagefind.build_outputs(
plan,
core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs
)
else
[]
end
@@ -433,7 +463,9 @@ defmodule BDS.Generation do
[]
end
core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs ++ sitemap ++ pagefind_outputs ++ asset_outputs
core_outputs ++
page_outputs ++
single_outputs ++ archive_outputs ++ sitemap ++ pagefind_outputs ++ asset_outputs
end
defp build_validation_sitemap_artifacts(
@@ -454,17 +486,27 @@ defmodule BDS.Generation do
additional_language_sets =
Enum.map(additional_languages(plan), fn language ->
language_posts = Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
language_list_posts = Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
language_posts =
Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
language_list_posts =
Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
language_post_index = build_generation_post_index(language_list_posts)
{language,
language_posts,
build_validation_route_paths(plan, language_posts, language_list_posts, language_post_index, language)}
{language, language_posts,
build_validation_route_paths(
plan,
language_posts,
language_list_posts,
language_post_index,
language
)}
end)
all_collection_paths =
main_paths ++ Enum.flat_map(additional_language_sets, fn {_language, _posts, paths} -> paths end)
main_paths ++
Enum.flat_map(additional_language_sets, fn {_language, _posts, paths} -> paths end)
total_route_count = max(length(all_collection_paths), 1)
@@ -497,7 +539,8 @@ defmodule BDS.Generation do
sitemap_to_write =
case additional_languages(plan) do
[] -> sitemap_content
[] ->
sitemap_content
languages ->
render_multi_language(
@@ -510,7 +553,8 @@ defmodule BDS.Generation do
)
end
{sitemap_content, sitemap_to_write, additional_expected_paths, additional_post_timestamp_checks}
{sitemap_content, sitemap_to_write, additional_expected_paths,
additional_post_timestamp_checks}
end
defp disk_generated_files(project_id) do
@@ -544,21 +588,52 @@ defmodule BDS.Generation do
segments = String.split(relative_path, "/", trim: true)
case strip_language_prefix(segments) do
["404.html"] -> :core
["index.html"] -> :core
["page", _page, "index.html"] -> :core
["sitemap.xml"] -> :core
["feed.xml"] -> :core
["atom.xml"] -> :core
["calendar.json"] -> :core
["pagefind" | _rest] -> :core
[year, month, day, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 -> :date
[year, month, day, _slug, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 -> :single
["category" | _rest] -> :category
["tag" | _rest] -> :tag
[year, "index.html"] when byte_size(year) == 4 -> :date
[year, month, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 -> :date
_other -> :core
["404.html"] ->
:core
["index.html"] ->
:core
["page", _page, "index.html"] ->
:core
["sitemap.xml"] ->
:core
["feed.xml"] ->
:core
["atom.xml"] ->
:core
["calendar.json"] ->
:core
["pagefind" | _rest] ->
:core
[year, month, day, "index.html"]
when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 ->
:date
[year, month, day, _slug, "index.html"]
when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 ->
:single
["category" | _rest] ->
:category
["tag" | _rest] ->
:tag
[year, "index.html"] when byte_size(year) == 4 ->
:date
[year, month, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 ->
:date
_other ->
:core
end
end
@@ -615,7 +690,9 @@ defmodule BDS.Generation do
generated_file.relative_path == ^relative_path
)
{pruned_count, _last_dir} = prune_empty_parent_dirs(Path.dirname(full_path), output_path(project, ""))
{pruned_count, _last_dir} =
prune_empty_parent_dirs(Path.dirname(full_path), output_path(project, ""))
{deleted_count + 1, removed_dir_count + pruned_count}
{:error, :enoent} ->
@@ -634,7 +711,12 @@ defmodule BDS.Generation do
end)
Enum.each(ancillary_paths, fn relative_path ->
_ = write_generated_file(project_id, relative_path, Map.fetch!(expected_output_map, relative_path))
_ =
write_generated_file(
project_id,
relative_path,
Map.fetch!(expected_output_map, relative_path)
)
end)
:ok

View File

@@ -40,7 +40,13 @@ defmodule BDS.Generation.Data do
post_snapshot_candidates
|> Enum.with_index(1)
|> Enum.reduce(%{}, fn {post, index}, acc ->
:ok = report_snapshot_stage_progress(on_snapshot_progress, :posts, index, length(post_snapshot_candidates))
:ok =
report_snapshot_stage_progress(
on_snapshot_progress,
:posts,
index,
length(post_snapshot_candidates)
)
case published_post_snapshot(project_data_dir, post) do
nil -> acc
@@ -54,7 +60,9 @@ defmodule BDS.Generation.Data do
|> then(fn published ->
draft_candidates
|> merge_generation_snapshots(snapshots_by_id)
|> Enum.reduce(Map.new(published, &{&1.id, &1}), fn post, acc -> Map.put(acc, post.id, post) end)
|> Enum.reduce(Map.new(published, &{&1.id, &1}), fn post, acc ->
Map.put(acc, post.id, post)
end)
|> Map.values()
end)
|> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)})
@@ -100,7 +108,12 @@ defmodule BDS.Generation.Data do
end
@spec resolve_posts_for_language([map()], String.t() | nil, map(), String.t() | nil) :: [map()]
def resolve_posts_for_language(posts, target_language, translations_by_post_language, main_language) do
def resolve_posts_for_language(
posts,
target_language,
translations_by_post_language,
main_language
) do
target = String.downcase(to_string(target_language || ""))
main = String.downcase(to_string(main_language || ""))
@@ -126,22 +139,42 @@ defmodule BDS.Generation.Data do
@spec build_generation_post_index([map()]) :: map()
def build_generation_post_index(posts) do
Enum.reduce(posts, %{posts_by_category: %{}, posts_by_tag: %{}, posts_by_year: %{}, posts_by_year_month: %{}, posts_by_year_month_day: %{}}, fn post, acc ->
{year, month_value, day_value} = local_date_parts!(post.created_at)
month = String.pad_leading(Integer.to_string(month_value), 2, "0")
day = String.pad_leading(Integer.to_string(day_value), 2, "0")
year_month = "#{year}/#{month}"
year_month_day = "#{year}/#{month}/#{day}"
Enum.reduce(
posts,
%{
posts_by_category: %{},
posts_by_tag: %{},
posts_by_year: %{},
posts_by_year_month: %{},
posts_by_year_month_day: %{}
},
fn post, acc ->
{year, month_value, day_value} = local_date_parts!(post.created_at)
month = String.pad_leading(Integer.to_string(month_value), 2, "0")
day = String.pad_leading(Integer.to_string(day_value), 2, "0")
year_month = "#{year}/#{month}"
year_month_day = "#{year}/#{month}/#{day}"
acc
|> append_generation_index(:posts_by_year, year, post)
|> append_generation_index(:posts_by_year_month, year_month, post)
|> append_generation_index(:posts_by_year_month_day, year_month_day, post)
|> then(fn indexed ->
indexed = Enum.reduce(post.categories || [], indexed, &append_generation_index(&2, :posts_by_category, &1, post))
Enum.reduce(post.tags || [], indexed, &append_generation_index(&2, :posts_by_tag, &1, post))
end)
end)
acc
|> append_generation_index(:posts_by_year, year, post)
|> append_generation_index(:posts_by_year_month, year_month, post)
|> append_generation_index(:posts_by_year_month_day, year_month_day, post)
|> then(fn indexed ->
indexed =
Enum.reduce(
post.categories || [],
indexed,
&append_generation_index(&2, :posts_by_category, &1, post)
)
Enum.reduce(
post.tags || [],
indexed,
&append_generation_index(&2, :posts_by_tag, &1, post)
)
end)
end
)
end
## --- internals -----------------------------------------------------------
@@ -168,9 +201,11 @@ defmodule BDS.Generation.Data do
"page" => %{render_in_lists: false, show_title: true}
}
Enum.reduce(Map.get(plan, :category_settings, %{}) || %{}, defaults, fn {category, settings}, acc ->
Enum.reduce(Map.get(plan, :category_settings, %{}) || %{}, defaults, fn {category, settings},
acc ->
Map.put(acc, category, %{
render_in_lists: category_setting_flag(settings, :render_in_lists, "render_in_lists", true),
render_in_lists:
category_setting_flag(settings, :render_in_lists, "render_in_lists", true),
show_title: category_setting_flag(settings, :show_title, "show_title", true)
})
end)
@@ -207,23 +242,30 @@ defmodule BDS.Generation.Data 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 || [])
%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} ->
@@ -231,13 +273,20 @@ defmodule BDS.Generation.Data do
end
end
defp build_generation_route_posts(project_id, project_data_dir, published_posts, on_snapshot_progress) do
defp build_generation_route_posts(
project_id,
project_data_dir,
published_posts,
on_snapshot_progress
) do
source_post_ids = Enum.map(published_posts, & &1.id)
translation_candidates =
Repo.all(
from translation in Translation,
where: translation.project_id == ^project_id and translation.translation_for in ^source_post_ids,
where:
translation.project_id == ^project_id and
translation.translation_for in ^source_post_ids,
where: translation.status in [:published, :draft],
order_by: [asc: translation.translation_for, asc: translation.language]
)
@@ -246,7 +295,13 @@ defmodule BDS.Generation.Data do
translation_candidates
|> Enum.with_index(1)
|> Enum.reduce(%{}, fn {translation, index}, acc ->
:ok = report_snapshot_stage_progress(on_snapshot_progress, :translations, index, length(translation_candidates))
:ok =
report_snapshot_stage_progress(
on_snapshot_progress,
:translations,
index,
length(translation_candidates)
)
case published_translation_snapshot(project_data_dir, translation) do
nil -> acc
@@ -288,18 +343,20 @@ defmodule BDS.Generation.Data 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
%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
}
{:error, _reason} ->

View File

@@ -25,8 +25,16 @@ defmodule BDS.Generation.Outputs do
end)
end
@spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [String.t()]
def build_validation_route_paths(plan, route_posts, published_list_posts, post_index, route_language) do
@spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [
String.t()
]
def build_validation_route_paths(
plan,
route_posts,
published_list_posts,
post_index,
route_language
) do
[
core_route_paths(plan, published_list_posts, route_language),
page_route_paths(plan, route_posts, route_language),
@@ -250,7 +258,9 @@ defmodule BDS.Generation.Outputs do
Enum.flat_map(posts_by_tag, fn {tag, posts} ->
tag_slug = archive_route_segment(tag)
build_paginated_archive_outputs(plan, languages, ["tag", tag_slug], posts, fn page_posts, language, pagination ->
build_paginated_archive_outputs(plan, languages, ["tag", tag_slug], posts, fn page_posts,
language,
pagination ->
render_archive_page(plan, tag, page_posts, language, "tag", pagination)
end)
end)
@@ -260,23 +270,31 @@ defmodule BDS.Generation.Outputs do
def build_date_outputs(plan, post_index, languages) do
year_outputs =
Enum.flat_map(post_index.posts_by_year, fn {year, posts} ->
build_paginated_archive_outputs(plan, languages, [Integer.to_string(year)], posts, fn page_posts, language, pagination ->
render_date_archive_page(
plan,
Integer.to_string(year),
%{kind: "year", year: year},
page_posts,
language,
pagination
)
end)
build_paginated_archive_outputs(
plan,
languages,
[Integer.to_string(year)],
posts,
fn page_posts, language, pagination ->
render_date_archive_page(
plan,
Integer.to_string(year),
%{kind: "year", year: year},
page_posts,
language,
pagination
)
end
)
end)
month_outputs =
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
[year, month] = String.split(year_month, "/", parts: 2)
build_paginated_archive_outputs(plan, languages, [year, month], posts, fn page_posts, language, pagination ->
build_paginated_archive_outputs(plan, languages, [year, month], posts, fn page_posts,
language,
pagination ->
render_date_archive_page(
plan,
"#{year}-#{month}",
@@ -292,11 +310,18 @@ defmodule BDS.Generation.Outputs do
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)
build_paginated_archive_outputs(plan, languages, [year, month, day], posts, fn page_posts, language, pagination ->
build_paginated_archive_outputs(plan, languages, [year, month, day], posts, fn page_posts,
language,
pagination ->
render_date_archive_page(
plan,
"#{year}-#{month}-#{day}",
%{kind: "day", year: String.to_integer(year), month: String.to_integer(month), day: String.to_integer(day)},
%{
kind: "day",
year: String.to_integer(year),
month: String.to_integer(month),
day: String.to_integer(day)
},
page_posts,
language,
pagination
@@ -323,19 +348,32 @@ defmodule BDS.Generation.Outputs do
Enum.flat_map(additional_languages, fn localized_language ->
localized_prefix = route_language(plan.language, localized_language)
localized_source_posts = Map.get(localized_posts_by_language, localized_language, [])
localized_posts = build_list_posts(plan.base_url, localized_source_posts, localized_prefix)
localized_posts =
build_list_posts(plan.base_url, localized_source_posts, localized_prefix)
build_root_outputs(plan, localized_language, localized_posts) ++
[
{Path.join(localized_language, "404.html"), render_not_found_output(plan, localized_language)},
{Path.join(localized_language, "feed.xml"), render_feed(plan, localized_language, localized_source_posts)},
{Path.join(localized_language, "atom.xml"), render_atom(plan, localized_language, localized_source_posts)}
{Path.join(localized_language, "404.html"),
render_not_found_output(plan, localized_language)},
{Path.join(localized_language, "feed.xml"),
render_feed(plan, localized_language, localized_source_posts)},
{Path.join(localized_language, "atom.xml"),
render_atom(plan, localized_language, localized_source_posts)}
]
end)
end
@spec build_page_outputs(String.t(), String.t(), [map()], map(), map()) :: [{String.t(), iodata()}]
def build_page_outputs(project_id, main_language, published_posts, translations_by_post_language, localized_posts_by_language) do
@spec build_page_outputs(String.t(), String.t(), [map()], map(), map()) :: [
{String.t(), iodata()}
]
def build_page_outputs(
project_id,
main_language,
published_posts,
translations_by_post_language,
localized_posts_by_language
) do
page_outputs =
published_posts
|> Enum.filter(&("page" in (&1.categories || [])))
@@ -355,7 +393,14 @@ defmodule BDS.Generation.Outputs do
language: canonical_variant.language,
excerpt: canonical_variant.excerpt
},
fn -> render_post_page(canonical_variant.title, body, post.slug, canonical_variant.language) end
fn ->
render_post_page(
canonical_variant.title,
body,
post.slug,
canonical_variant.language
)
end
)}
end)
@@ -404,13 +449,22 @@ defmodule BDS.Generation.Outputs do
plan.project_name,
page_posts,
%{kind: "core"},
pagination_for_page(page_number, total_pages, length(posts), plan.max_posts_per_page, route_language, []),
pagination_for_page(
page_number,
total_pages,
length(posts),
plan.max_posts_per_page,
route_language,
[]
),
fn -> render_home(plan, language) end
)}
end)
end
@spec build_paginated_archive_outputs(map(), [String.t()], [String.t()], [map()], (... -> iodata())) :: [{String.t(), iodata()}]
@spec build_paginated_archive_outputs(map(), [String.t()], [String.t()], [map()], (... ->
iodata())) ::
[{String.t(), iodata()}]
def build_paginated_archive_outputs(plan, languages, segments, posts, render_fun) do
total_pages = page_count(length(posts), plan.max_posts_per_page)
@@ -425,13 +479,22 @@ defmodule BDS.Generation.Outputs do
render_fun.(
page_posts,
language,
pagination_for_page(page_number, total_pages, length(posts), plan.max_posts_per_page, route_language, segments)
pagination_for_page(
page_number,
total_pages,
length(posts),
plan.max_posts_per_page,
route_language,
segments
)
)}
end)
end)
end
@spec build_single_outputs(String.t(), String.t(), [map()], map(), map()) :: [{String.t(), iodata()}]
@spec build_single_outputs(String.t(), String.t(), [map()], map(), map()) :: [
{String.t(), iodata()}
]
def build_single_outputs(
project_id,
main_language,
@@ -457,7 +520,12 @@ defmodule BDS.Generation.Outputs do
excerpt: canonical_variant.excerpt
},
fn ->
render_post_page(canonical_variant.title, body, post.slug, canonical_variant.language)
render_post_page(
canonical_variant.title,
body,
post.slug,
canonical_variant.language
)
end
)}
end)

View File

@@ -19,10 +19,13 @@ defmodule BDS.Generation.Pagefind do
|> Enum.flat_map(fn language ->
route_language = route_language(plan.language, language)
pages = pages_for_language(html_outputs, route_language)
prefix = if route_language in [nil, ""], do: ["pagefind"], else: [route_language, "pagefind"]
prefix =
if route_language in [nil, ""], do: ["pagefind"], else: [route_language, "pagefind"]
[
{Path.join(prefix ++ ["index.json"]), Jason.encode!(%{"language" => language, "pages" => pages})},
{Path.join(prefix ++ ["index.json"]),
Jason.encode!(%{"language" => language, "pages" => pages})},
{Path.join(prefix ++ ["pagefind-ui.js"]), ui_js(language)},
{Path.join(prefix ++ ["pagefind-ui.css"]), ui_css()}
]

View File

@@ -49,18 +49,37 @@ defmodule BDS.Generation.Paths do
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(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"])
def root_output_path(route_language, page_number),
do: Path.join([route_language, "page", Integer.to_string(page_number), "index.html"])
@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"])
@spec pagination_for_page(pos_integer(), pos_integer(), non_neg_integer(), pos_integer(), language(), [String.t()]) ::
@spec pagination_for_page(
pos_integer(),
pos_integer(),
non_neg_integer(),
pos_integer(),
language(),
[String.t()]
) ::
map()
def pagination_for_page(page_number, total_pages, total_items, items_per_page, route_language, segments) do
def pagination_for_page(
page_number,
total_pages,
total_items,
items_per_page,
route_language,
segments
) do
%{
current_page: page_number,
total_pages: total_pages,
@@ -75,8 +94,12 @@ defmodule BDS.Generation.Paths do
@spec archive_or_root_href(language(), [String.t()], integer()) :: String.t()
def archive_or_root_href(_route_language, _segments, page_number) when page_number < 1, do: ""
def archive_or_root_href(route_language, [], page_number), do: root_page_href(route_language, page_number)
def archive_or_root_href(route_language, segments, page_number), do: archive_href(route_language, segments, page_number)
def archive_or_root_href(route_language, [], page_number),
do: root_page_href(route_language, page_number)
def archive_or_root_href(route_language, segments, page_number),
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
@@ -147,7 +170,9 @@ defmodule BDS.Generation.Paths do
@spec archive_route_segment(any()) :: String.t()
def archive_route_segment(nil), do: ""
def archive_route_segment(value), do: value |> to_string() |> URI.encode(&URI.char_unreserved?/1)
def archive_route_segment(value),
do: value |> to_string() |> URI.encode(&URI.char_unreserved?/1)
@spec normalize_base_url(String.t() | nil) :: String.t() | nil
def normalize_base_url(nil), do: nil

View File

@@ -44,7 +44,8 @@ defmodule BDS.Generation.Renderers do
end
@doc "Render an archive page (category, tag, year) with pagination."
@spec render_archive_page(map(), String.t(), [map()], String.t() | nil, String.t(), map()) :: String.t()
@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 =
@@ -130,7 +131,15 @@ defmodule BDS.Generation.Renderers do
end
@doc "Render a list/archive page through the project template, falling back to inline."
@spec render_list_output(map(), String.t() | nil, String.t(), [map()], map(), map(), (-> String.t())) ::
@spec render_list_output(
map(),
String.t() | nil,
String.t(),
[map()],
map(),
map(),
(-> String.t())
) ::
String.t()
def render_list_output(
%{project_id: project_id, language: main_language},

View File

@@ -34,17 +34,20 @@ defmodule BDS.Generation.Sitemap do
build_hreflang_links(plan.base_url, "/", plan.language, all_languages)
)
] ++
Enum.map(Paths.root_pagination_pages(length(published_list_posts), plan.max_posts_per_page), fn page_number ->
page_path = "/page/#{page_number}"
Enum.map(
Paths.root_pagination_pages(length(published_list_posts), plan.max_posts_per_page),
fn page_number ->
page_path = "/page/#{page_number}"
url_entry(
Paths.url_for_path(plan.base_url, page_path),
latest_post_updated_at,
"daily",
"0.9",
build_hreflang_links(plan.base_url, page_path, plan.language, all_languages)
)
end) ++
url_entry(
Paths.url_for_path(plan.base_url, page_path),
latest_post_updated_at,
"daily",
"0.9",
build_hreflang_links(plan.base_url, page_path, plan.language, all_languages)
)
end
) ++
Enum.map(translatable_posts, fn post ->
post_path = Paths.relative_path_to_url_path(Paths.post_output_path(post))
@@ -100,28 +103,34 @@ defmodule BDS.Generation.Sitemap do
build_hreflang_links(plan.base_url, year_path, plan.language, all_languages)
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc), fn {year_month, _posts} ->
month_path = "/#{year_month}"
Enum.map(
Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc),
fn {year_month, _posts} ->
month_path = "/#{year_month}"
url_entry(
Paths.url_for_path(plan.base_url, month_path),
latest_post_updated_at,
"monthly",
"0.5",
build_hreflang_links(plan.base_url, month_path, plan.language, all_languages)
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_year_month_day, &elem(&1, 0), :desc), fn {year_month_day, _posts} ->
day_path = "/#{year_month_day}"
url_entry(
Paths.url_for_path(plan.base_url, month_path),
latest_post_updated_at,
"monthly",
"0.5",
build_hreflang_links(plan.base_url, month_path, plan.language, all_languages)
)
end
) ++
Enum.map(
Enum.sort_by(post_index.posts_by_year_month_day, &elem(&1, 0), :desc),
fn {year_month_day, _posts} ->
day_path = "/#{year_month_day}"
url_entry(
Paths.url_for_path(plan.base_url, day_path),
latest_post_updated_at,
"monthly",
"0.4",
build_hreflang_links(plan.base_url, day_path, plan.language, all_languages)
)
end) ++
url_entry(
Paths.url_for_path(plan.base_url, day_path),
latest_post_updated_at,
"monthly",
"0.4",
build_hreflang_links(plan.base_url, day_path, 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)}"

View File

@@ -9,6 +9,7 @@ defmodule BDS.Generation.Validation do
relative_path_to_url_path: 1,
url_path_to_relative_index_path: 1
]
import BDS.Generation.Progress, only: [report_validation_compare_progress: 3]
import BDS.Generation.Sitemap, only: [extract_locs: 1, loc_to_project_path: 2]
@@ -20,7 +21,11 @@ defmodule BDS.Generation.Validation do
end
@spec build_post_timestamp_checks(String.t(), [map()], map()) :: [map()]
def build_post_timestamp_checks(project_data_dir, published_route_posts, generated_file_updated_at) do
def build_post_timestamp_checks(
project_data_dir,
published_route_posts,
generated_file_updated_at
) do
Enum.map(published_route_posts, fn post ->
relative_path = BDS.Generation.Paths.post_output_path(post)
@@ -69,13 +74,19 @@ defmodule BDS.Generation.Validation do
|> Enum.map(&loc_to_project_path(&1, params.base_url))
|> Enum.reduce(MapSet.new(), &MapSet.put(&2, normalize_url_path(&1)))
|> then(fn expected_paths ->
Enum.reduce(Map.get(params, :additional_expected_paths, []), expected_paths, fn path, acc ->
Enum.reduce(Map.get(params, :additional_expected_paths, []), expected_paths, fn path,
acc ->
MapSet.put(acc, normalize_url_path(path))
end)
end)
{existing_html_path_set, zero_byte_html_path_set} =
collect_html_index_paths(index_paths, params.html_dir, params.on_progress, total_compare_steps)
collect_html_index_paths(
index_paths,
params.html_dir,
params.on_progress,
total_compare_steps
)
missing_url_paths =
expected_path_set
@@ -119,11 +130,14 @@ defmodule BDS.Generation.Validation do
acc
true ->
html_path = Path.join(params.html_dir, url_path_to_relative_index_path(normalized_url_path))
html_path =
Path.join(params.html_dir, url_path_to_relative_index_path(normalized_url_path))
case {File.stat(html_path, time: :posix), File.stat(check.post_file_path, time: :posix)} do
case {File.stat(html_path, time: :posix),
File.stat(check.post_file_path, time: :posix)} do
{{:ok, html_stat}, {:ok, post_stat}} ->
effective_generated_at_ms = max(mtime_ms(html_stat), check.generated_updated_at_ms || 0)
effective_generated_at_ms =
max(mtime_ms(html_stat), check.generated_updated_at_ms || 0)
if mtime_ms(post_stat) > effective_generated_at_ms do
MapSet.put(acc, normalized_url_path)
@@ -233,7 +247,18 @@ defmodule BDS.Generation.Validation do
nil ->
case Regex.run(~r|^/(\d{4})/(\d{2})/(\d{2})/([^/]+)$|, path) do
[_, year, month, day, slug] ->
update_in(plan.requested_post_routes, &[ %{year: String.to_integer(year), month: String.to_integer(month), day: String.to_integer(day), slug: slug} | &1 ])
update_in(
plan.requested_post_routes,
&[
%{
year: String.to_integer(year),
month: String.to_integer(month),
day: String.to_integer(day),
slug: slug
}
| &1
]
)
nil ->
case Regex.run(~r|^/(\d{4})/(\d{2})(?:/page/\d+)?$|, path) do
@@ -281,29 +306,43 @@ defmodule BDS.Generation.Validation do
end)
enriched =
Enum.reduce(initial_plan.requested_post_routes, %{initial_plan | requested_post_routes: targeted_post_routes}, fn route, acc ->
case Enum.find(published_posts, &post_matches_route?(&1, route)) do
nil ->
acc
|> update_in([:requested_years], &MapSet.put(&1, route.year))
|> update_in([:requested_year_months], &MapSet.put(&1, route_month_key(route.year, route.month)))
|> Map.put(:request_root_routes, true)
Enum.reduce(
initial_plan.requested_post_routes,
%{initial_plan | requested_post_routes: targeted_post_routes},
fn route, acc ->
case Enum.find(published_posts, &post_matches_route?(&1, route)) do
nil ->
acc
|> update_in([:requested_years], &MapSet.put(&1, route.year))
|> update_in(
[:requested_year_months],
&MapSet.put(&1, route_month_key(route.year, route.month))
)
|> Map.put(:request_root_routes, true)
post ->
{year, month, _day} = local_date_parts!(post.created_at)
post ->
{year, month, _day} = local_date_parts!(post.created_at)
acc
|> update_in([:requested_category_slugs], fn set ->
Enum.reduce(post.categories || [], set, &MapSet.put(&2, archive_route_segment(&1)))
end)
|> update_in([:requested_tag_slugs], fn set ->
Enum.reduce(post.tags || [], set, &MapSet.put(&2, archive_route_segment(&1)))
end)
|> update_in([:requested_years], &MapSet.put(&1, year))
|> update_in([:requested_year_months], &MapSet.put(&1, route_month_key(year, month)))
|> Map.put(:request_root_routes, true)
acc
|> update_in([:requested_category_slugs], fn set ->
Enum.reduce(
post.categories || [],
set,
&MapSet.put(&2, archive_route_segment(&1))
)
end)
|> update_in([:requested_tag_slugs], fn set ->
Enum.reduce(post.tags || [], set, &MapSet.put(&2, archive_route_segment(&1)))
end)
|> update_in([:requested_years], &MapSet.put(&1, year))
|> update_in(
[:requested_year_months],
&MapSet.put(&1, route_month_key(year, month))
)
|> Map.put(:request_root_routes, true)
end
end
end)
)
language_plans =
initial_plan.language_plans
@@ -314,8 +353,10 @@ defmodule BDS.Generation.Validation do
%{
enriched
| requested_category_slugs: MapSet.intersection(enriched.requested_category_slugs, available_category_slugs),
requested_tag_slugs: MapSet.intersection(enriched.requested_tag_slugs, available_tag_slugs),
| requested_category_slugs:
MapSet.intersection(enriched.requested_category_slugs, available_category_slugs),
requested_tag_slugs:
MapSet.intersection(enriched.requested_tag_slugs, available_tag_slugs),
language_plans: language_plans
}
end
@@ -351,13 +392,15 @@ defmodule BDS.Generation.Validation do
{nil, path}
end
_other -> {nil, path}
_other ->
{nil, path}
end
end
@spec targeted_output?(String.t(), map(), String.t() | nil, [String.t()]) :: boolean()
def targeted_output?(relative_path, targeted_plan, main_language, additional_languages) do
{language, stripped_path} = extract_relative_output_language(relative_path, additional_languages)
{language, stripped_path} =
extract_relative_output_language(relative_path, additional_languages)
plan =
case language do
@@ -384,7 +427,11 @@ defmodule BDS.Generation.Validation do
end
end
defp targeted_output_for_plan?(_relative_path, %{requires_fallback_section_render: true}, _main?), do: true
defp targeted_output_for_plan?(
_relative_path,
%{requires_fallback_section_render: true},
_main?
), do: true
defp targeted_output_for_plan?(relative_path, plan, _main?) do
cond do
@@ -400,8 +447,18 @@ defmodule BDS.Generation.Validation do
MapSet.member?(plan.requested_tag_slugs, slug)
Regex.match?(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path) ->
[_, year, month, day, slug] = Regex.run(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path)
MapSet.member?(plan.requested_post_routes, route_key(String.to_integer(year), String.to_integer(month), String.to_integer(day), slug))
[_, year, month, day, slug] =
Regex.run(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path)
MapSet.member?(
plan.requested_post_routes,
route_key(
String.to_integer(year),
String.to_integer(month),
String.to_integer(day),
slug
)
)
Regex.match?(~r|^(\d{4})/(\d{2})/index\.html$|, relative_path) ->
[_, year, month] = Regex.run(~r|^(\d{4})/(\d{2})/index\.html$|, relative_path)

View File

@@ -59,7 +59,9 @@ defmodule BDS.Git do
has_lfs: has_lfs_configured?(project_dir)
}}
else
{:error, :not_found} = error -> error
{:error, :not_found} = error ->
error
{:error, _reason} ->
{:ok,
%{
@@ -74,7 +76,8 @@ defmodule BDS.Git do
def status(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id),
{:ok, output} <- run_git(project_dir, ["status", "--porcelain=v1", "--untracked-files=all"], opts) do
{:ok, output} <-
run_git(project_dir, ["status", "--porcelain=v1", "--untracked-files=all"], opts) do
{:ok, %{files: parse_status(output)}}
end
end
@@ -112,7 +115,8 @@ defmodule BDS.Git do
when is_binary(project_id) and is_binary(branch) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id),
{:ok, local_log} <- run_git(project_dir, ["log", "--format=%H%x09%s", branch], opts),
{:ok, remote_log} <- run_git(project_dir, ["log", "--format=%H", "origin/#{branch}"], opts) do
{:ok, remote_log} <-
run_git(project_dir, ["log", "--format=%H", "origin/#{branch}"], opts) do
local_commits = parse_local_history(local_log)
remote_hashes = MapSet.new(parse_remote_history(remote_log))
local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash))
@@ -121,7 +125,9 @@ defmodule BDS.Git do
remote_hashes
|> MapSet.difference(local_hashes)
|> MapSet.to_list()
|> Enum.map(fn hash -> %{hash: hash, subject: nil, sync_status: %{kind: :remote_only}} end)
|> Enum.map(fn hash ->
%{hash: hash, subject: nil, sync_status: %{kind: :remote_only}}
end)
commits =
Enum.map(local_commits, fn commit ->
@@ -136,7 +142,8 @@ defmodule BDS.Git do
def file_history(project_id, file_path, opts \\ [])
when is_binary(project_id) and is_binary(file_path) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id),
{:ok, output} <- run_git(project_dir, ["log", "--follow", "--format=%H%x09%s", "--", file_path], opts) do
{:ok, output} <-
run_git(project_dir, ["log", "--follow", "--format=%H%x09%s", "--", file_path], opts) do
{:ok, %{commits: parse_local_history(output) |> Enum.take(50)}}
else
{:error, {:git_failed, _message}} -> {:ok, %{commits: []}}
@@ -147,8 +154,11 @@ defmodule BDS.Git do
def fetch(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id) do
case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do
{:ok, output} -> {:ok, %{updated: true, output: output}}
{:error, {:git_failed, message}} -> structured_git_error(project_dir, :fetch, message, opts)
{:ok, output} ->
{:ok, %{updated: true, output: output}}
{:error, {:git_failed, message}} ->
structured_git_error(project_dir, :fetch, message, opts)
end
end
end
@@ -177,9 +187,11 @@ defmodule BDS.Git do
end
def reconcile(project_id, old_commit, new_commit, opts \\ [])
when is_binary(project_id) and is_binary(old_commit) and is_binary(new_commit) and is_list(opts) do
when is_binary(project_id) and is_binary(old_commit) and is_binary(new_commit) and
is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id),
{:ok, output} <- run_git(project_dir, ["diff", "--name-status", old_commit, new_commit], opts) do
{:ok, output} <-
run_git(project_dir, ["diff", "--name-status", old_commit, new_commit], opts) do
{:ok, %{changed: parse_changed_files(output)}}
end
end
@@ -197,7 +209,14 @@ defmodule BDS.Git do
{:ok, local_branch} <- current_branch(project_dir, opts) do
case upstream_branch(project_dir, opts) do
{:ok, nil} ->
{:ok, %{local_branch: local_branch, upstream_branch: nil, has_upstream: false, ahead: 0, behind: 0}}
{:ok,
%{
local_branch: local_branch,
upstream_branch: nil,
has_upstream: false,
ahead: 0,
behind: 0
}}
{:ok, upstream_branch} ->
{:ok,
@@ -316,7 +335,11 @@ defmodule BDS.Git do
end
defp upstream_branch(project_dir, opts) do
case run_git(project_dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], opts) do
case run_git(
project_dir,
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
opts
) do
{:ok, output} -> {:ok, blank_to_nil(output)}
{:error, {:git_failed, _message}} -> {:ok, nil}
end
@@ -364,21 +387,37 @@ defmodule BDS.Git do
defp parse_changed_files(output) do
base = fn -> %{added: [], modified: [], deleted: [], renamed: []} end
Enum.reduce(String.split(output, "\n", trim: true), %{posts: base.(), scripts: base.(), templates: base.()}, fn line, acc ->
case String.split(line, "\t", trim: true) do
["A", path] -> update_changed(acc, path, :added, path)
["M", path] -> update_changed(acc, path, :modified, path)
["D", path] -> update_changed(acc, path, :deleted, path)
["R" <> _score, old_path, new_path] -> update_changed(acc, new_path, :renamed, %{old: old_path, new: new_path})
_other -> acc
Enum.reduce(
String.split(output, "\n", trim: true),
%{posts: base.(), scripts: base.(), templates: base.()},
fn line, acc ->
case String.split(line, "\t", trim: true) do
["A", path] ->
update_changed(acc, path, :added, path)
["M", path] ->
update_changed(acc, path, :modified, path)
["D", path] ->
update_changed(acc, path, :deleted, path)
["R" <> _score, old_path, new_path] ->
update_changed(acc, new_path, :renamed, %{old: old_path, new: new_path})
_other ->
acc
end
end
end)
)
end
defp update_changed(acc, path, key, value) do
case category_for_path(path) do
nil -> acc
category -> Map.update!(acc, category, &Map.update!(&1, key, fn items -> items ++ [value] end))
nil ->
acc
category ->
Map.update!(acc, category, &Map.update!(&1, key, fn items -> items ++ [value] end))
end
end
@@ -427,6 +466,7 @@ defmodule BDS.Git do
defp auth_guidance(provider, platform) do
provider_label = provider || :git
"Authentication failed for #{provider_label} on #{platform}. Configure SSH keys or a credential helper that works non-interactively."
end

View File

@@ -32,11 +32,23 @@ defmodule BDS.ImportAnalysis do
notify_progress(on_progress, "Loading existing posts...")
existing_posts = Repo.all(from post in Post, where: post.project_id == ^project_id)
notify_progress(on_progress, "Loading existing media...", "#{length(existing_posts)} posts in project")
notify_progress(
on_progress,
"Loading existing media...",
"#{length(existing_posts)} posts in project"
)
existing_media = Repo.all(from media in Media, where: media.project_id == ^project_id)
notify_progress(on_progress, "Loading existing tags...", "#{length(existing_media)} media in project")
existing_tag_names = Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: tag.name)
notify_progress(
on_progress,
"Loading existing tags...",
"#{length(existing_media)} media in project"
)
existing_tag_names =
Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: tag.name)
existing_tag_set = existing_tag_names |> Enum.map(&String.downcase/1) |> MapSet.new()
posts_by_slug = Map.new(existing_posts, &{&1.slug, &1})
@@ -53,15 +65,35 @@ defmodule BDS.ImportAnalysis do
|> Enum.reject(&is_nil(&1.checksum))
|> Map.new(&{&1.checksum, &1})
notify_progress(on_progress, "Analyzing posts...", "#{length(wxr_data.posts)} posts to analyze")
analyzed_posts = Enum.map(wxr_data.posts, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "post"))
notify_progress(
on_progress,
"Analyzing posts...",
"#{length(wxr_data.posts)} posts to analyze"
)
notify_progress(on_progress, "Analyzing pages...", "#{length(wxr_data.pages)} pages to analyze")
analyzed_pages = Enum.map(wxr_data.pages, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "page"))
analyzed_posts =
Enum.map(wxr_data.posts, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "post"))
notify_progress(
on_progress,
"Analyzing pages...",
"#{length(wxr_data.pages)} pages to analyze"
)
analyzed_pages =
Enum.map(wxr_data.pages, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "page"))
notify_progress(
on_progress,
"Analyzing media files...",
"#{length(wxr_data.media)} media files to analyze"
)
notify_progress(on_progress, "Analyzing media files...", "#{length(wxr_data.media)} media files to analyze")
analyzed_media =
Enum.map(wxr_data.media, &analyze_media_item(&1, uploads_folder_path, media_by_name, media_by_checksum))
Enum.map(
wxr_data.media,
&analyze_media_item(&1, uploads_folder_path, media_by_name, media_by_checksum)
)
notify_progress(on_progress, "Processing categories and tags...")
category_items = Enum.map(wxr_data.categories, &analyze_taxonomy_item(&1, existing_tag_set))
@@ -113,10 +145,18 @@ defmodule BDS.ImportAnalysis do
{status, existing} =
cond do
existing_by_slug && existing_by_slug.checksum == content_checksum && not is_nil(existing_by_slug.checksum) -> {"update", existing_by_slug}
existing_by_slug -> {"conflict", existing_by_slug}
existing_by_checksum -> {"content-duplicate", existing_by_checksum}
true -> {"new", nil}
existing_by_slug && existing_by_slug.checksum == content_checksum &&
not is_nil(existing_by_slug.checksum) ->
{"update", existing_by_slug}
existing_by_slug ->
{"conflict", existing_by_slug}
existing_by_checksum ->
{"content-duplicate", existing_by_checksum}
true ->
{"new", nil}
end
%{
@@ -163,10 +203,18 @@ defmodule BDS.ImportAnalysis do
existing_by_checksum = Map.get(media_by_checksum, file_checksum)
cond do
existing_by_name && existing_by_name.checksum == file_checksum && not is_nil(existing_by_name.checksum) -> {"update", file_checksum, existing_by_name}
existing_by_name -> {"conflict", file_checksum, existing_by_name}
existing_by_checksum -> {"content-duplicate", file_checksum, existing_by_checksum}
true -> {"new", file_checksum, nil}
existing_by_name && existing_by_name.checksum == file_checksum &&
not is_nil(existing_by_name.checksum) ->
{"update", file_checksum, existing_by_name}
existing_by_name ->
{"conflict", file_checksum, existing_by_name}
existing_by_checksum ->
{"content-duplicate", file_checksum, existing_by_checksum}
true ->
{"new", file_checksum, nil}
end
end
@@ -265,7 +313,9 @@ defmodule BDS.ImportAnalysis do
defp date_distribution(posts, pages, media) do
combined_posts = posts ++ pages
post_counts = Enum.reduce(combined_posts, %{}, &increment_year(&1.created_at || &1.published_at, &2))
post_counts =
Enum.reduce(combined_posts, %{}, &increment_year(&1.created_at || &1.published_at, &2))
media_counts = Enum.reduce(media, %{}, &increment_year(&1.created_at, &2))
post_counts
@@ -325,7 +375,10 @@ defmodule BDS.ImportAnalysis do
| total_count: existing.total_count + 1,
usages: Map.put(existing.usages, params_key, usage),
post_slugs:
if(is_binary(slug), do: MapSet.put(existing.post_slugs, slug), else: existing.post_slugs)
if(is_binary(slug),
do: MapSet.put(existing.post_slugs, slug),
else: existing.post_slugs
)
}
Map.put(inner_acc, name, updated)
@@ -393,9 +446,17 @@ defmodule BDS.ImportAnalysis do
defp year_from(value) when is_integer(value) do
cond do
value > 100_000_000_000 -> value |> DateTime.from_unix!(:millisecond) |> DateTime.shift_zone!("Etc/UTC") |> Map.get(:year)
value > 1_000_000_000 -> value |> DateTime.from_unix!(:second) |> Map.get(:year)
true -> value
value > 100_000_000_000 ->
value
|> DateTime.from_unix!(:millisecond)
|> DateTime.shift_zone!("Etc/UTC")
|> Map.get(:year)
value > 1_000_000_000 ->
value |> DateTime.from_unix!(:second) |> Map.get(:year)
true ->
value
end
rescue
_error -> nil
@@ -405,10 +466,14 @@ defmodule BDS.ImportAnalysis do
normalized = String.replace(value, " ", "T")
case NaiveDateTime.from_iso8601(normalized) do
{:ok, naive} -> naive.year
{:ok, naive} ->
naive.year
_other ->
case DateTime.from_iso8601(value) do
{:ok, datetime, _offset} -> datetime.year
{:ok, datetime, _offset} ->
datetime.year
_ ->
case Regex.run(~r/(\d{4})/, value) do
[_, year] -> String.to_integer(year)

View File

@@ -39,7 +39,10 @@ defmodule BDS.ImportDefinitions do
|> 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(:last_analysis_result, normalize_analysis_result(attr(attrs, :last_analysis_result)))
|> maybe_put(
:last_analysis_result,
normalize_analysis_result(attr(attrs, :last_analysis_result))
)
|> Map.put(:updated_at, Persistence.now_ms())
definition
@@ -50,7 +53,9 @@ defmodule BDS.ImportDefinitions do
def delete_definition(definition_id) when is_binary(definition_id) do
case Repo.get(ImportDefinition, definition_id) do
nil -> {:error, :not_found}
nil ->
{:error, :not_found}
%ImportDefinition{} = definition ->
Repo.delete(definition)
|> case do
@@ -60,7 +65,8 @@ defmodule BDS.ImportDefinitions do
end
end
def decode_analysis_result(%ImportDefinition{last_analysis_result: result}), do: decode_analysis_result(result)
def decode_analysis_result(%ImportDefinition{last_analysis_result: result}),
do: decode_analysis_result(result)
def decode_analysis_result(result) when is_binary(result) do
case Jason.decode(result) do

View File

@@ -19,7 +19,16 @@ defmodule BDS.ImportDefinitions.ImportDefinition do
def changeset(definition, attrs) do
definition
|> cast(attrs, [:id, :project_id, :name, :wxr_file_path, :uploads_folder_path, :last_analysis_result, :created_at, :updated_at])
|> cast(attrs, [
:id,
:project_id,
:name,
:wxr_file_path,
:uploads_folder_path,
:last_analysis_result,
:created_at,
:updated_at
])
|> validate_required([:id, :project_id, :name, :created_at, :updated_at])
end
end

View File

@@ -8,7 +8,8 @@ defmodule BDS.ImportExecution do
alias BDS.Repo
alias BDS.Tags
def execute_import(project_id, report, opts \\ []) when is_binary(project_id) and is_map(report) do
def execute_import(project_id, report, opts \\ [])
when is_binary(project_id) and is_map(report) do
normalized_report = normalize_report(report)
default_author = Keyword.get(opts, :default_author) || project_default_author(project_id)
uploads_folder_path = Keyword.get(opts, :uploads_folder_path)
@@ -42,16 +43,52 @@ defmodule BDS.ImportExecution do
started_at = System.monotonic_time(:millisecond)
notify_progress(on_progress, "tags", 0, taxonomy_total, "creating_tags", started_at)
result = execute_taxonomies(category_items, tag_items, project_id, result, on_progress, started_at)
result =
execute_taxonomies(category_items, tag_items, project_id, result, on_progress, started_at)
notify_progress(on_progress, "posts", 0, length(post_items), "importing_posts", started_at)
result = execute_posts(post_items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, :posts, started_at)
result =
execute_posts(
post_items,
project_id,
default_author,
tag_mapping,
category_mapping,
result,
on_progress,
:posts,
started_at
)
notify_progress(on_progress, "media", 0, length(media_items), "importing_media", started_at)
result = execute_media(media_items, project_id, default_author, result, on_progress, uploads_folder_path, started_at)
result =
execute_media(
media_items,
project_id,
default_author,
result,
on_progress,
uploads_folder_path,
started_at
)
notify_progress(on_progress, "pages", 0, length(page_items), "importing_pages", started_at)
result = execute_posts(page_items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, :pages, started_at)
result =
execute_posts(
page_items,
project_id,
default_author,
tag_mapping,
category_mapping,
result,
on_progress,
:pages,
started_at
)
notify_progress(on_progress, "complete", 1, 1, "import_complete", started_at)
{:ok, result}
@@ -68,41 +105,99 @@ defmodule BDS.ImportExecution do
|> Enum.reduce(result, fn {item, index}, acc ->
cond do
Map.get(item, :exists_in_project) || not is_nil(Map.get(item, :mapped_to)) ->
notify_progress(on_progress, "tags", index, total, "skipped_tag:#{item.name}", started_at)
notify_progress(
on_progress,
"tags",
index,
total,
"skipped_tag:#{item.name}",
started_at
)
put_in(acc, [:tags, :skipped], acc.tags.skipped + 1)
true ->
case Tags.create_tag(%{project_id: project_id, name: item.name}) do
{:ok, _tag} ->
notify_progress(on_progress, "tags", index, total, "created_tag:#{item.name}", started_at)
notify_progress(
on_progress,
"tags",
index,
total,
"created_tag:#{item.name}",
started_at
)
put_in(acc, [:tags, :created], acc.tags.created + 1)
{:error, _reason} ->
notify_progress(on_progress, "tags", index, total, "skipped_tag:#{item.name}", started_at)
notify_progress(
on_progress,
"tags",
index,
total,
"skipped_tag:#{item.name}",
started_at
)
put_in(acc, [:tags, :skipped], acc.tags.skipped + 1)
end
end
end)
end
defp execute_posts(items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, bucket, started_at) do
defp execute_posts(
items,
project_id,
default_author,
tag_mapping,
category_mapping,
result,
on_progress,
bucket,
started_at
) do
total = length(items)
phase = Atom.to_string(bucket)
Enum.with_index(items, 1)
|> Enum.reduce(result, fn {item, index}, acc ->
notify_progress(on_progress, phase, index, total, "processing:#{item.title}", started_at)
execute_post_item(project_id, maybe_apply_page_category(item, bucket), acc, bucket, default_author, tag_mapping, category_mapping)
execute_post_item(
project_id,
maybe_apply_page_category(item, bucket),
acc,
bucket,
default_author,
tag_mapping,
category_mapping
)
end)
end
defp execute_media(items, project_id, default_author, result, on_progress, uploads_folder_path, started_at) do
defp execute_media(
items,
project_id,
default_author,
result,
on_progress,
uploads_folder_path,
started_at
) do
total = length(items)
items
|> Enum.with_index(1)
|> Enum.reduce(result, fn {item, index}, acc ->
notify_progress(on_progress, "media", index, total, "processing:#{item.filename}", started_at)
notify_progress(
on_progress,
"media",
index,
total,
"processing:#{item.filename}",
started_at
)
cond do
item.status == "missing" ->
@@ -116,7 +211,9 @@ defmodule BDS.ImportExecution do
true ->
case import_media_item(project_id, item, default_author, uploads_folder_path, acc) do
{:ok, _media} -> put_in(acc, [:media, :imported], acc.media.imported + 1)
{:ok, _media} ->
put_in(acc, [:media, :imported], acc.media.imported + 1)
{:error, reason} ->
acc
|> put_in([:media, :errors], acc.media.errors + 1)
@@ -127,7 +224,15 @@ defmodule BDS.ImportExecution do
end)
end
defp execute_post_item(project_id, item, result, bucket, default_author, tag_mapping, category_mapping) do
defp execute_post_item(
project_id,
item,
result,
bucket,
default_author,
tag_mapping,
category_mapping
) do
cond do
item.status in ["update", "content-duplicate", "duplicate"] ->
put_in(result, [bucket, :skipped], get_in(result, [bucket, :skipped]) + 1)
@@ -177,7 +282,8 @@ defmodule BDS.ImportExecution do
defp overwrite_post_item(item, default_author, tag_mapping, category_mapping) do
case Repo.get(Post, item.existing_id) do
nil -> {:error, :not_found}
nil ->
{:error, :not_found}
%Post{} = post ->
Posts.update_post(post.id, %{
@@ -194,7 +300,13 @@ defmodule BDS.ImportExecution do
defp import_media_item(project_id, item, default_author, uploads_folder_path, result) do
source_path = item.source_file || uploads_source_path(item.relative_path, uploads_folder_path)
checksum = if(source_path != nil and File.exists?(source_path), do: md5(File.read!(source_path)), else: nil)
checksum =
if(source_path != nil and File.exists?(source_path),
do: md5(File.read!(source_path)),
else: nil
)
linked_post_ids = parent_post_ids(item, result)
if source_path && File.exists?(source_path) do
@@ -221,7 +333,10 @@ defmodule BDS.ImportExecution do
checksum: checksum
}
attrs = if linked_post_ids == [], do: attrs, else: Map.put(attrs, :linked_post_ids, linked_post_ids)
attrs =
if linked_post_ids == [],
do: attrs,
else: Map.put(attrs, :linked_post_ids, linked_post_ids)
case Media.import_media(attrs) do
{:ok, %{id: media_id} = media} ->
@@ -255,8 +370,12 @@ defmodule BDS.ImportExecution do
defp parent_post_ids(item, result) do
case Map.get(item, :parent_wp_id) do
nil -> []
0 -> []
nil ->
[]
0 ->
[]
wp_id ->
case Map.get(result.wp_id_to_post_id, wp_id) do
nil -> []
@@ -265,7 +384,8 @@ defmodule BDS.ImportExecution do
end
end
defp track_wp_id(result, %{wp_id: wp_id}, %{id: post_id}) when is_integer(wp_id) and not is_nil(post_id) do
defp track_wp_id(result, %{wp_id: wp_id}, %{id: post_id})
when is_integer(wp_id) and not is_nil(post_id) do
update_in(result, [:wp_id_to_post_id], &Map.put(&1, wp_id, post_id))
end
@@ -333,7 +453,9 @@ defmodule BDS.ImportExecution do
end
defp maybe_apply_page_category(item, :pages) do
categories = (Map.get(item, :categories) || []) |> Enum.uniq() |> Enum.concat(["page"]) |> Enum.uniq()
categories =
(Map.get(item, :categories) || []) |> Enum.uniq() |> Enum.concat(["page"]) |> Enum.uniq()
%{item | categories: categories}
end
@@ -349,7 +471,11 @@ defmodule BDS.ImportExecution do
true -> key
end
Map.put(acc, key, %{resolved: resolved, needs_creation: not item.exists_in_project and not present_string?(Map.get(item, :mapped_to))})
Map.put(acc, key, %{
resolved: resolved,
needs_creation:
not item.exists_in_project and not present_string?(Map.get(item, :mapped_to))
})
end)
end
@@ -443,13 +569,15 @@ defmodule BDS.ImportExecution do
defp uploads_source_path(relative_path, uploads_folder_path)
defp uploads_source_path(relative_path, uploads_folder_path)
when is_binary(relative_path) and is_binary(uploads_folder_path) and uploads_folder_path != "" do
when is_binary(relative_path) and is_binary(uploads_folder_path) and
uploads_folder_path != "" do
Path.join(uploads_folder_path, relative_path)
end
defp uploads_source_path(_relative_path, _uploads_folder_path), do: nil
defp notify_progress(callback, phase, current, total, detail, started_at) when is_function(callback, 4) do
defp notify_progress(callback, phase, current, total, detail, started_at)
when is_function(callback, 4) do
eta = compute_eta(current, total, started_at)
try do
@@ -466,7 +594,9 @@ defmodule BDS.ImportExecution do
:ok
end
defp compute_eta(current, total, started_at) when is_integer(current) and is_integer(total) and current > 0 and total > 0 and current <= total do
defp compute_eta(current, total, started_at)
when is_integer(current) and is_integer(total) and current > 0 and total > 0 and
current <= total do
elapsed = System.monotonic_time(:millisecond) - started_at
if current >= total, do: 0, else: trunc(elapsed / current * (total - current))
end

View File

@@ -114,9 +114,11 @@ defmodule BDS.Maintenance do
phases = [
{"Comparing project metadata", fn -> project_metadata_diff_reports(project_id) end},
{"Comparing post metadata", fn -> post_diff_reports(project_id, project) end},
{"Comparing post translations", fn -> post_translation_diff_reports(project_id, project) end},
{"Comparing post translations",
fn -> post_translation_diff_reports(project_id, project) end},
{"Comparing media metadata", fn -> media_diff_reports(project_id, project) end},
{"Comparing media translations", fn -> media_translation_diff_reports(project_id, project) end},
{"Comparing media translations",
fn -> media_translation_diff_reports(project_id, project) end},
{"Comparing script metadata", fn -> script_diff_reports(project_id, project) end},
{"Comparing template metadata", fn -> template_diff_reports(project_id, project) end},
{"Comparing embeddings", fn -> Embeddings.diff_reports(project_id) end}
@@ -132,7 +134,9 @@ defmodule BDS.Maintenance do
fun.()
end)
:ok = report_metadata_diff_phase(on_progress, total_phases, total_phases, "Scanning orphan files")
:ok =
report_metadata_diff_phase(on_progress, total_phases, total_phases, "Scanning orphan files")
orphan_reports = orphan_reports(project_id, project)
:ok = report_metadata_diff_complete(on_progress)

View File

@@ -87,7 +87,10 @@ defmodule BDS.Maintenance.DiffComputation do
end
def normalize_nested_diff_value(value) when is_map(value), do: normalize_map_diff_values(value)
def normalize_nested_diff_value(value) when is_list(value), do: Enum.map(value, &normalize_nested_diff_value/1)
def normalize_nested_diff_value(value) when is_list(value),
do: Enum.map(value, &normalize_nested_diff_value/1)
def normalize_nested_diff_value(value) when is_atom(value), do: Atom.to_string(value)
def normalize_nested_diff_value(value), do: value
end

View File

@@ -110,10 +110,18 @@ defmodule BDS.Maintenance.DiffReports do
diff_field("author", post.author, Map.get(fields, "author")),
diff_field("language", post.language, Map.get(fields, "language")),
diff_field("status", post.status, DocumentFields.get(fields, "status")),
diff_field("template_slug", post.template_slug, DocumentFields.get(fields, "templateSlug")),
diff_field(
"template_slug",
post.template_slug,
DocumentFields.get(fields, "templateSlug")
),
diff_field("created_at", post.created_at, DocumentFields.get(fields, "createdAt")),
diff_field("updated_at", post.updated_at, DocumentFields.get(fields, "updatedAt")),
diff_field("published_at", post.published_at, DocumentFields.get(fields, "publishedAt")),
diff_field(
"published_at",
post.published_at,
DocumentFields.get(fields, "publishedAt")
),
diff_field("tags", post.tags, Map.get(fields, "tags", [])),
diff_field("categories", post.categories, Map.get(fields, "categories", []))
]
@@ -265,7 +273,11 @@ defmodule BDS.Maintenance.DiffReports do
diff_field("title", script.title, Map.get(fields, "title")),
diff_field("entrypoint", script.entrypoint, Map.get(fields, "entrypoint")),
diff_field("enabled", script.enabled, Map.get(fields, "enabled")),
diff_field("created_at", script.created_at, DocumentFields.get(fields, "createdAt")),
diff_field(
"created_at",
script.created_at,
DocumentFields.get(fields, "createdAt")
),
diff_field("updated_at", script.updated_at, DocumentFields.get(fields, "updatedAt"))
]
|> Enum.reject(&is_nil/1)
@@ -296,8 +308,16 @@ defmodule BDS.Maintenance.DiffReports do
[
diff_field("title", template.title, Map.get(fields, "title")),
diff_field("enabled", template.enabled, Map.get(fields, "enabled")),
diff_field("created_at", template.created_at, DocumentFields.get(fields, "createdAt")),
diff_field("updated_at", template.updated_at, DocumentFields.get(fields, "updatedAt"))
diff_field(
"created_at",
template.created_at,
DocumentFields.get(fields, "createdAt")
),
diff_field(
"updated_at",
template.updated_at,
DocumentFields.get(fields, "updatedAt")
)
]
|> Enum.reject(&is_nil/1)

View File

@@ -30,7 +30,9 @@ defmodule BDS.MCP.AgentConfig do
end
def config_path(:claude_code, home_dir), do: Path.join(home_dir, ".claude.json")
def config_path(:github_copilot, home_dir), do: Path.join([home_dir, "Library", "Application Support", "Code", "User", "mcp.json"])
def config_path(:github_copilot, home_dir),
do: Path.join([home_dir, "Library", "Application Support", "Code", "User", "mcp.json"])
def packaged_executable_path(install_root, platform) when is_binary(install_root) do
executable_name =
@@ -90,12 +92,21 @@ defmodule BDS.MCP.AgentConfig do
defp merge_config(:github_copilot, config, command, args) do
servers = Map.get(config, "servers", %{})
Map.put(config, "servers", Map.put(servers, @server_name, %{"type" => "stdio", "command" => command, "args" => args}))
Map.put(
config,
"servers",
Map.put(servers, @server_name, %{"type" => "stdio", "command" => command, "args" => args})
)
end
defp merge_config(:claude_code, config, command, args) do
servers = Map.get(config, "mcpServers", %{})
Map.put(config, "mcpServers", Map.put(servers, @server_name, %{"command" => command, "args" => args}))
Map.put(
config,
"mcpServers",
Map.put(servers, @server_name, %{"command" => command, "args" => args})
)
end
defp remove_server_entry(:github_copilot, config) do

View File

@@ -8,7 +8,11 @@ defmodule BDS.MCP.Proposal do
schema "mcp_proposals" do
field :kind, :string
field :status, Ecto.Enum, values: [:pending, :accepted, :discarded, :expired], default: :pending
field :status, Ecto.Enum,
values: [:pending, :accepted, :discarded, :expired],
default: :pending
field :entity_id, :string
field :data, :map
field :created_at, :integer
@@ -17,7 +21,9 @@ defmodule BDS.MCP.Proposal do
def changeset(proposal, attrs) do
proposal
|> cast(attrs, [:id, :kind, :status, :entity_id, :data, :created_at, :expires_at], empty_values: [nil])
|> cast(attrs, [:id, :kind, :status, :entity_id, :data, :created_at, :expires_at],
empty_values: [nil]
)
|> validate_required([:id, :kind, :status, :entity_id, :data, :created_at, :expires_at])
|> unique_constraint(:status, name: :mcp_proposals_entity_idx)
end

View File

@@ -74,12 +74,15 @@ defmodule BDS.MCP.ProposalStore do
defp mark_status(id, status) do
case Repo.get(Proposal, id) do
nil -> nil
nil ->
nil
proposal ->
Repo.delete_all(
from other in Proposal,
where:
other.id != ^id and other.kind == ^proposal.kind and other.entity_id == ^proposal.entity_id and
other.id != ^id and other.kind == ^proposal.kind and
other.entity_id == ^proposal.entity_id and
other.status == ^status
)
@@ -90,6 +93,7 @@ defmodule BDS.MCP.ProposalStore do
end
defp derive_entity_id(data) do
data["post_id"] || data["script_id"] || data["template_id"] || data["media_id"] || Ecto.UUID.generate()
data["post_id"] || data["script_id"] || data["template_id"] || data["media_id"] ||
Ecto.UUID.generate()
end
end

View File

@@ -138,8 +138,11 @@ defmodule BDS.MCP.Server 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)
{: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
_other ->
@@ -170,7 +173,10 @@ defmodule BDS.MCP.Server do
success_response(id, %{
"protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"),
"capabilities" => %{"tools" => %{}, "resources" => %{}},
"serverInfo" => %{"name" => @server_name, "version" => Application.spec(:bds, :vsn) |> to_string()}
"serverInfo" => %{
"name" => @server_name,
"version" => Application.spec(:bds, :vsn) |> to_string()
}
})}
"tools/list" ->
@@ -196,10 +202,17 @@ defmodule BDS.MCP.Server do
arguments = Map.get(params, "arguments", %{})
case BDS.MCP.call_tool(name, arguments) do
{:ok, result} -> {:ok, success_response(id, %{"content" => [%{"type" => "json", "json" => result}]})}
{:error, :unknown_tool} -> {:error, error_response(id, -32601, "Unknown tool")}
{:error, :not_found} -> {:error, error_response(id, -32004, "Not found")}
{:error, reason} -> {:error, error_response(id, -32000, inspect(reason))}
{:ok, result} ->
{:ok, success_response(id, %{"content" => [%{"type" => "json", "json" => result}]})}
{:error, :unknown_tool} ->
{:error, error_response(id, -32601, "Unknown tool")}
{:error, :not_found} ->
{:error, error_response(id, -32004, "Not found")}
{:error, reason} ->
{:error, error_response(id, -32000, inspect(reason))}
end
end
@@ -286,7 +299,8 @@ defmodule BDS.MCP.Server do
|> IO.iodata_to_binary()
end
defp http_error_response(status, headers \\ %{}), do: http_response(status, reason_body(status), "text/plain", headers)
defp http_error_response(status, headers \\ %{}),
do: http_response(status, reason_body(status), "text/plain", headers)
defp reason_body(400), do: "Bad Request"
defp reason_body(404), do: "Not Found"

View File

@@ -9,8 +9,15 @@ defmodule BDS.MCP.Stdio do
if line != "" do
response =
case Jason.decode(line) do
{:ok, payload} -> handle_payload(payload)
{:error, _reason} -> %{"jsonrpc" => "2.0", "id" => nil, "error" => %{"code" => -32700, "message" => "Parse error"}}
{:ok, payload} ->
handle_payload(payload)
{:error, _reason} ->
%{
"jsonrpc" => "2.0",
"id" => nil,
"error" => %{"code" => -32700, "message" => "Parse error"}
}
end
IO.write(Jason.encode!(response) <> "\n")
@@ -18,14 +25,22 @@ defmodule BDS.MCP.Stdio do
end)
end
defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "initialize", "params" => params}) do
defp handle_payload(%{
"jsonrpc" => "2.0",
"id" => id,
"method" => "initialize",
"params" => params
}) do
%{
"jsonrpc" => "2.0",
"id" => id,
"result" => %{
"protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"),
"capabilities" => %{"tools" => %{}, "resources" => %{}},
"serverInfo" => %{"name" => "Blogging Desktop Server", "version" => Application.spec(:bds, :vsn) |> to_string()}
"serverInfo" => %{
"name" => "Blogging Desktop Server",
"version" => Application.spec(:bds, :vsn) |> to_string()
}
}
}
end
@@ -34,10 +49,26 @@ defmodule BDS.MCP.Stdio do
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"tools" => BDS.MCP.list_tools()}}
end
defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "tools/call", "params" => %{"name" => name} = params}) do
defp handle_payload(%{
"jsonrpc" => "2.0",
"id" => id,
"method" => "tools/call",
"params" => %{"name" => name} = params
}) do
case BDS.MCP.call_tool(name, Map.get(params, "arguments", %{})) do
{:ok, result} -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"content" => [%{"type" => "json", "json" => result}]}}
{:error, reason} -> %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32000, "message" => inspect(reason)}}
{:ok, result} ->
%{
"jsonrpc" => "2.0",
"id" => id,
"result" => %{"content" => [%{"type" => "json", "json" => result}]}
}
{:error, reason} ->
%{
"jsonrpc" => "2.0",
"id" => id,
"error" => %{"code" => -32000, "message" => inspect(reason)}
}
end
end
@@ -45,17 +76,38 @@ defmodule BDS.MCP.Stdio do
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"resources" => BDS.MCP.list_resources()}}
end
defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "resources/read", "params" => %{"uri" => uri}}) do
defp handle_payload(%{
"jsonrpc" => "2.0",
"id" => id,
"method" => "resources/read",
"params" => %{"uri" => uri}
}) do
case BDS.MCP.read_resource(uri) do
{:ok, result} ->
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"contents" => [%{"uri" => uri, "mimeType" => "application/json", "text" => Jason.encode!(result)}]}}
%{
"jsonrpc" => "2.0",
"id" => id,
"result" => %{
"contents" => [
%{"uri" => uri, "mimeType" => "application/json", "text" => Jason.encode!(result)}
]
}
}
{:error, reason} ->
%{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32000, "message" => inspect(reason)}}
%{
"jsonrpc" => "2.0",
"id" => id,
"error" => %{"code" => -32000, "message" => inspect(reason)}
}
end
end
defp handle_payload(%{"jsonrpc" => "2.0", "id" => id}) do
%{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32601, "message" => "Method not found"}}
%{
"jsonrpc" => "2.0",
"id" => id,
"error" => %{"code" => -32601, "message" => "Method not found"}
}
end
end

View File

@@ -60,8 +60,11 @@ defmodule BDS.MCP.Tools do
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
def validate_template(source) when is_binary(source) do
case Liquex.parse(source) do
{:ok, _ast} -> {:ok, %{valid: true, errors: []}}
{:error, reason, line} -> {:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}}
{:ok, _ast} ->
{:ok, %{valid: true, errors: []}}
{:error, reason, line} ->
{:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}}
end
end
@@ -276,7 +279,8 @@ defmodule BDS.MCP.Tools do
ttl_ms: @proposal_ttl_app_ms
)
{:ok, %{"proposal_id" => proposal.id, "current" => sanitize(media), "proposed" => changes}}
{:ok,
%{"proposal_id" => proposal.id, "current" => sanitize(media), "proposed" => changes}}
end
end

View File

@@ -19,7 +19,8 @@ defmodule BDS.Media.Rebuilder do
@type rebuild_opts :: keyword()
@spec rebuild_media_from_files(String.t(), rebuild_opts()) :: {:ok, [Media.t()]} | {:error, term()}
@spec rebuild_media_from_files(String.t(), rebuild_opts()) ::
{:ok, [Media.t()]} | {:error, term()}
def rebuild_media_from_files(project_id, opts \\ []) do
project = Projects.get_project!(project_id)
on_progress = progress_callback(opts)
@@ -61,9 +62,10 @@ defmodule BDS.Media.Rebuilder do
translation_sidecars
|> Enum.with_index(length(canonical_sidecars) + 1)
|> Enum.each(fn {sidecar, index} ->
Sidecars.upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar,
sync_search: false
)
Sidecars.upsert_translation_from_sidecar(
project,
canonical_media_by_binary_path,
sidecar, sync_search: false)
:ok = report_rebuild_progress(on_progress, index, total_files, "media files")
end)

View File

@@ -141,7 +141,12 @@ defmodule BDS.Media.Sidecars do
media
end
@spec upsert_translation_from_sidecar(BDS.Projects.Project.t(), %{required(Path.t()) => Media.t()}, map(), keyword()) ::
@spec upsert_translation_from_sidecar(
BDS.Projects.Project.t(),
%{required(Path.t()) => Media.t()},
map(),
keyword()
) ::
Translation.t() | :skip | :ok
def upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar, opts) do
case Map.get(canonical_media_by_binary_path, sidecar.binary_path) do

View File

@@ -70,7 +70,9 @@ defmodule BDS.Media.Thumbnails do
missing_paths =
media
|> thumbnail_paths()
|> Enum.map(fn {_size, relative_path} -> Path.join(Projects.project_data_dir(project), relative_path) end)
|> Enum.map(fn {_size, relative_path} ->
Path.join(Projects.project_data_dir(project), relative_path)
end)
|> Enum.reject(&File.exists?/1)
next_acc =

View File

@@ -17,7 +17,9 @@ defmodule BDS.Persistence do
value
|> String.trim()
|> case do
"" -> nil
"" ->
nil
trimmed ->
case Integer.parse(trimmed) do
{integer, ""} -> normalize_unix_timestamp(integer)

View File

@@ -9,6 +9,7 @@ defmodule BDS.PostLinks do
alias BDS.Projects
alias BDS.Repo
@spec sync_post_links(Post.t()) :: :ok
def sync_post_links(%Post{} = post) do
links =
post
@@ -41,6 +42,7 @@ defmodule BDS.PostLinks do
:ok
end
@spec delete_post_links(String.t()) :: :ok
def delete_post_links(post_id) when is_binary(post_id) do
Repo.delete_all(
from link in Link,
@@ -50,12 +52,18 @@ defmodule BDS.PostLinks do
:ok
end
@spec list_outgoing_links(String.t()) :: [Link.t()]
def list_outgoing_links(post_id) when is_binary(post_id) do
Repo.all(from link in Link, where: link.source_post_id == ^post_id, order_by: [asc: link.created_at])
Repo.all(
from link in Link, where: link.source_post_id == ^post_id, order_by: [asc: link.created_at]
)
end
@spec list_incoming_links(String.t()) :: [Link.t()]
def list_incoming_links(post_id) when is_binary(post_id) do
Repo.all(from link in Link, where: link.target_post_id == ^post_id, order_by: [asc: link.created_at])
Repo.all(
from link in Link, where: link.target_post_id == ^post_id, order_by: [asc: link.created_at]
)
end
defp post_body(%Post{content: content}) when is_binary(content), do: content
@@ -83,11 +91,15 @@ defmodule BDS.PostLinks do
defp extract_links(body) when is_binary(body) do
markdown_links =
Regex.scan(~r/\[([^\]]+)\]\(([^)]+)\)/, body)
|> Enum.map(fn [_full, link_text, href] -> %{link_text: normalize_link_text(link_text), href: href} end)
|> Enum.map(fn [_full, link_text, href] ->
%{link_text: normalize_link_text(link_text), href: href}
end)
html_links =
Regex.scan(~r/<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/is, body)
|> Enum.map(fn [_full, href, link_text] -> %{link_text: normalize_link_text(link_text), href: href} end)
|> Enum.map(fn [_full, href, link_text] ->
%{link_text: normalize_link_text(link_text), href: href}
end)
markdown_links ++ html_links
end
@@ -121,12 +133,17 @@ defmodule BDS.PostLinks do
[language, year, month, day, slug] ->
if language_code?(language) and numeric_year?(year) and numeric_month_or_day?(month) and
numeric_month_or_day?(day),
do: slug,
else: nil
do: slug,
else: nil
[slug] -> slug
[language, slug] -> if(language_code?(language), do: slug, else: nil)
_other -> nil
[slug] ->
slug
[language, slug] ->
if(language_code?(language), do: slug, else: nil)
_other ->
nil
end
end

View File

@@ -122,7 +122,8 @@ defmodule BDS.Posts.AutoTranslation do
defp media_needed?(media_id, language) do
case Repo.get(Media.Media, media_id) do
%Media.Media{language: source_language} when source_language not in [nil, ""] and source_language != language ->
%Media.Media{language: source_language}
when source_language not in [nil, ""] and source_language != language ->
not Repo.exists?(
from translation in Media.Translation,
where: translation.translation_for == ^media_id and translation.language == ^language

View File

@@ -18,8 +18,15 @@ defmodule BDS.Posts.Link do
}
schema "post_links" do
belongs_to :source_post, BDS.Posts.Post, foreign_key: :source_post_id, references: :id, type: :string
belongs_to :target_post, BDS.Posts.Post, foreign_key: :target_post_id, references: :id, type: :string
belongs_to :source_post, BDS.Posts.Post,
foreign_key: :source_post_id,
references: :id,
type: :string
belongs_to :target_post, BDS.Posts.Post,
foreign_key: :target_post_id,
references: :id,
type: :string
field :link_text, :string
field :created_at, :integer

View File

@@ -50,7 +50,11 @@ defmodule BDS.Posts.TranslationValidation do
Repo.all(
from translation in Translation,
where: translation.project_id == ^project_id,
order_by: [asc: translation.translation_for, asc: translation.language, asc: translation.id]
order_by: [
asc: translation.translation_for,
asc: translation.language,
asc: translation.id
]
)
project_data_dir = Projects.project_data_dir(project)
@@ -67,7 +71,13 @@ defmodule BDS.Posts.TranslationValidation do
translation_rows
|> Enum.with_index(1)
|> Enum.flat_map(fn {translation, index} ->
:ok = RebuildFromFiles.report_rebuild_progress(on_progress, index, total_items, "translations")
:ok =
RebuildFromFiles.report_rebuild_progress(
on_progress,
index,
total_items,
"translations"
)
case invalid_database_translation_issue(translation, source_post_map, metadata) do
nil -> []
@@ -80,7 +90,13 @@ defmodule BDS.Posts.TranslationValidation do
markdown_files
|> Enum.with_index(length(translation_rows) + 1)
|> Enum.reduce({0, []}, fn {file_path, index}, {count, issues} ->
:ok = RebuildFromFiles.report_rebuild_progress(on_progress, index, total_items, "translations")
:ok =
RebuildFromFiles.report_rebuild_progress(
on_progress,
index,
total_items,
"translations"
)
case invalid_filesystem_translation_issue(file_path, source_post_map, metadata) do
{:ok, nil} -> {count + 1, issues}
@@ -118,11 +134,19 @@ defmodule BDS.Posts.TranslationValidation do
normalized_report = normalize_report(report)
{deleted_database_rows, flushed_translations, synced_post_ids} =
Enum.reduce(normalized_report.invalid_database_rows, {0, 0, MapSet.new()}, fn issue, {deleted, flushed, synced_ids} ->
Enum.reduce(normalized_report.invalid_database_rows, {0, 0, MapSet.new()}, fn issue,
{deleted,
flushed,
synced_ids} ->
case fix_invalid_database_row(issue) do
{:deleted, post_id} -> {deleted + 1, flushed, maybe_put_synced_post(synced_ids, post_id)}
{:flushed, post_id} -> {deleted, flushed + 1, maybe_put_synced_post(synced_ids, post_id)}
:noop -> {deleted, flushed, synced_ids}
{:deleted, post_id} ->
{deleted + 1, flushed, maybe_put_synced_post(synced_ids, post_id)}
{:flushed, post_id} ->
{deleted, flushed + 1, maybe_put_synced_post(synced_ids, post_id)}
:noop ->
{deleted, flushed, synced_ids}
end
end)
@@ -365,7 +389,10 @@ defmodule BDS.Posts.TranslationValidation do
end
end
defp fix_invalid_database_row(%{translation_id: translation_id, translation_for: translation_for})
defp fix_invalid_database_row(%{
translation_id: translation_id,
translation_for: translation_for
})
when is_binary(translation_id) do
case Repo.get(Translation, translation_id) do
%Translation{} = translation ->
@@ -402,7 +429,11 @@ defmodule BDS.Posts.TranslationValidation do
end
defp issue_sort_key(issue) do
[Map.get(issue, :translation_for), Map.get(issue, :translation_id), Map.get(issue, :file_path)]
[
Map.get(issue, :translation_for),
Map.get(issue, :translation_id),
Map.get(issue, :file_path)
]
|> Enum.map(&to_string(&1 || ""))
|> Enum.join(":")
end

View File

@@ -64,7 +64,11 @@ defmodule BDS.Preview do
{:reply, reply, next_state}
end
def handle_call({:ensure_preview, project_id, _data_dir, _owner_pid}, _from, %{current: %{project_id: project_id, is_running: true}} = state) do
def handle_call(
{:ensure_preview, project_id, _data_dir, _owner_pid},
_from,
%{current: %{project_id: project_id, is_running: true}} = state
) do
{:reply, {:ok, public_server(state.current)}, state}
end
@@ -224,7 +228,9 @@ defmodule BDS.Preview do
end
defp draft_preview_translation(_post_id, nil, _post_language), do: nil
defp draft_preview_translation(_post_id, requested_language, post_language) when requested_language == post_language, do: nil
defp draft_preview_translation(_post_id, requested_language, post_language)
when requested_language == post_language, do: nil
defp draft_preview_translation(post_id, requested_language, _post_language) do
Repo.get_by(Translation, translation_for: post_id, language: requested_language)
@@ -456,7 +462,10 @@ defmodule BDS.Preview do
{uri.path || "/", URI.decode_query(uri.query || "")}
end
defp apply_response_overrides(%{content_type: content_type, body: body} = response, query_params)
defp apply_response_overrides(
%{content_type: content_type, body: body} = response,
query_params
)
when is_binary(content_type) and is_binary(body) do
if String.starts_with?(content_type, "text/html") do
%{response | body: apply_preview_overrides(body, query_params)}
@@ -465,7 +474,8 @@ defmodule BDS.Preview do
end
end
defp apply_preview_overrides(body, query_params) when is_binary(body) and is_map(query_params) do
defp apply_preview_overrides(body, query_params)
when is_binary(body) and is_map(query_params) do
theme_override = normalize_pico_theme_override(query_params["theme"])
mode_override = normalize_mode_override(query_params["mode"])
@@ -506,7 +516,9 @@ defmodule BDS.Preview do
[html_tag] ->
replacement =
if String.contains?(html_tag, attribute <> "=") do
Regex.replace(~r/\s#{attribute}="[^"]*"/, html_tag, ~s( #{attribute}="#{value}"), global: false)
Regex.replace(~r/\s#{attribute}="[^"]*"/, html_tag, ~s( #{attribute}="#{value}"),
global: false
)
else
String.replace_suffix(html_tag, ">", ~s( #{attribute}="#{value}">))
end
@@ -520,7 +532,11 @@ defmodule BDS.Preview do
defp not_found_assigns(query_params) do
%{}
|> maybe_put_assign("pico_stylesheet_href", normalize_pico_theme_override(query_params["theme"]), &PreviewAssets.stylesheet_href/1)
|> maybe_put_assign(
"pico_stylesheet_href",
normalize_pico_theme_override(query_params["theme"]),
&PreviewAssets.stylesheet_href/1
)
end
defp maybe_put_assign(assigns, _key, nil, _mapper), do: assigns

View File

@@ -134,7 +134,8 @@ defmodule BDS.Projects do
sync_filesystem_metadata(project)
end
{:error, reason} -> {:error, reason}
{:error, reason} ->
{:error, reason}
end
end
@@ -166,7 +167,8 @@ defmodule BDS.Projects do
@spec delete_project(String.t()) ::
{:ok, Project.t()}
| {:error, :not_found | :cannot_delete_default_project | :cannot_delete_active_project | term()}
| {:error,
:not_found | :cannot_delete_default_project | :cannot_delete_active_project | term()}
def delete_project(project_id) when is_binary(project_id) do
case Repo.get(Project, project_id) do
nil ->
@@ -180,7 +182,9 @@ defmodule BDS.Projects do
%Project{} = project ->
internal_dir = if is_nil(project.data_path), do: project_data_dir(project), else: nil
cleanup_dirs = [internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
cleanup_dirs =
[internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
Repo.transaction(fn ->
Repo.delete!(project)

View File

@@ -7,7 +7,11 @@ defmodule BDS.Rebuild do
timeout = Keyword.get(opts, :timeout, :infinity)
items
|> Task.async_stream(mapper, max_concurrency: max_concurrency, ordered: ordered, timeout: timeout)
|> Task.async_stream(mapper,
max_concurrency: max_concurrency,
ordered: ordered,
timeout: timeout
)
|> Enum.map(fn
{:ok, item} -> item
{:exit, reason} -> exit(reason)

View File

@@ -26,7 +26,8 @@ defmodule BDS.ReleasePackaging do
]
end
def build_metadata(platform, version, output_dir) when is_binary(version) and is_binary(output_dir) do
def build_metadata(platform, version, output_dir)
when is_binary(version) and is_binary(output_dir) do
normalized_platform = normalize_platform(platform)
payload_name = "bds2-#{normalized_platform}-#{version}"
payload_root = Path.join(output_dir, payload_name)
@@ -66,7 +67,9 @@ defmodule BDS.ReleasePackaging do
defp normalize_platform(platform) when platform in [:macos, :linux, :windows], do: platform
defp normalize_platform(:darwin), do: :macos
defp normalize_platform(platform) when is_binary(platform), do: platform |> String.downcase() |> String.to_atom()
defp normalize_platform(platform) when is_binary(platform),
do: platform |> String.downcase() |> String.to_atom()
defp archive_extension(:windows), do: ".zip"
defp archive_extension(_platform), do: ".tar.gz"
@@ -107,7 +110,9 @@ defmodule BDS.ReleasePackaging do
relative_entries = collect_entries(metadata.payload_root)
cwd = metadata.output_dir |> String.to_charlist()
archive = metadata.archive_path |> String.to_charlist()
entries = Enum.map(relative_entries, &String.to_charlist(Path.join(metadata.payload_name, &1)))
entries =
Enum.map(relative_entries, &String.to_charlist(Path.join(metadata.payload_name, &1)))
case :zip.create(archive, entries, cwd: cwd) do
{:ok, _archive_path} -> :ok
@@ -116,7 +121,13 @@ defmodule BDS.ReleasePackaging do
end
defp create_archive(metadata) do
case System.cmd("tar", ["-czf", metadata.archive_path, "-C", metadata.output_dir, metadata.payload_name]) do
case System.cmd("tar", [
"-czf",
metadata.archive_path,
"-C",
metadata.output_dir,
metadata.payload_name
]) do
{_output, 0} -> :ok
{output, status} -> {:error, {:tar_failed, status, output}}
end

View File

@@ -7,9 +7,14 @@ defmodule BDS.Rendering do
def render_post_page(project_id, template_slug, assigns)
when is_binary(project_id) and is_map(assigns) do
with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :post, template_slug),
with {:ok, template_source} <-
TemplateSelection.load_template_source(project_id, :post, template_slug),
{:ok, rendered} <-
TemplateSelection.render_template(project_id, template_source, PostRendering.post_assigns(project_id, assigns)) do
TemplateSelection.render_template(
project_id,
template_source,
PostRendering.post_assigns(project_id, assigns)
) do
{:ok, rendered}
end
end
@@ -17,16 +22,25 @@ defmodule BDS.Rendering do
def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do
with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :list, nil),
{:ok, rendered} <-
TemplateSelection.render_template(project_id, template_source, ListArchive.list_assigns(project_id, assigns)) do
TemplateSelection.render_template(
project_id,
template_source,
ListArchive.list_assigns(project_id, assigns)
) do
{:ok, rendered}
end
end
def render_not_found_page(project_id, assigns \\ %{})
when is_binary(project_id) and is_map(assigns) do
with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :not_found, nil),
with {:ok, template_source} <-
TemplateSelection.load_template_source(project_id, :not_found, nil),
{:ok, rendered} <-
TemplateSelection.render_template(project_id, template_source, ListArchive.not_found_assigns(project_id, assigns)) do
TemplateSelection.render_template(
project_id,
template_source,
ListArchive.not_found_assigns(project_id, assigns)
) do
{:ok, rendered}
end
end

View File

@@ -54,7 +54,9 @@ defmodule BDS.Rendering.Metadata do
|> Enum.uniq()
|> Enum.map(fn language ->
normalized = I18n.normalize_language(language)
href_prefix = LinksAndLanguages.language_prefix(normalized, metadata.main_language || current_language)
href_prefix =
LinksAndLanguages.language_prefix(normalized, metadata.main_language || current_language)
%{
code: normalized,
@@ -84,9 +86,17 @@ defmodule BDS.Rendering.Metadata do
order_by: [asc: translation.language]
)
[%{href: LinksAndLanguages.post_path(post, nil), hreflang: LinksAndLanguages.normalize_language(post.language, main_language)}] ++
[
%{
href: LinksAndLanguages.post_path(post, nil),
hreflang: LinksAndLanguages.normalize_language(post.language, main_language)
}
] ++
Enum.map(translations, fn translation ->
%{href: LinksAndLanguages.post_path(post, translation.language, main_language), hreflang: translation.language}
%{
href: LinksAndLanguages.post_path(post, translation.language, main_language),
hreflang: translation.language
}
end)
end

View File

@@ -36,7 +36,9 @@ defmodule BDS.Scripting do
runtime().execute(source, entrypoint, args, opts)
end
@spec execute_project_script(String.t(), String.t(), String.t(), [term()], [Runtime.execution_option()]) ::
@spec execute_project_script(String.t(), String.t(), String.t(), [term()], [
Runtime.execution_option()
]) ::
{:ok, term()} | {:error, term()}
def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ [])
when is_binary(project_id) and is_binary(source) and is_binary(entrypoint) and
@@ -45,13 +47,20 @@ defmodule BDS.Scripting do
execute(source, entrypoint, args, Keyword.put(opts, :capabilities, capabilities))
end
@spec execute_macro(String.t(), String.t(), [term()], keyword()) :: {:ok, String.t()} | {:error, term()}
@spec execute_macro(String.t(), String.t(), [term()], keyword()) ::
{:ok, String.t()} | {:error, term()}
def execute_macro(project_id, source, args, opts \\ [])
when is_binary(project_id) and is_binary(source) and is_list(args) and is_list(opts) do
config = Application.fetch_env!(:bds, :scripting)
timeout = Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout))
case execute_project_script(project_id, source, "render", args, Keyword.put(opts, :timeout, timeout)) do
case execute_project_script(
project_id,
source,
"render",
args,
Keyword.put(opts, :timeout, timeout)
) do
{:ok, nil} -> {:ok, ""}
{:ok, value} -> {:ok, to_string(value)}
{:error, _reason} -> {:ok, ""}

Some files were not shown because too many files have changed in this diff Show More