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

@@ -107,7 +107,7 @@ _None._ All modules previously on the queue have been split; refresh the queue i
## 10. Missing `@spec` ## 10. Missing `@spec`
**Status:** ✅ done for core contexts (2026-04-30). Open for LiveView editor modules and the smaller contexts (`Tags`, `Templates`, `Scripts`, `PostLinks`). **Status:** ✅ done (2026-05-01). Core contexts, LiveView editor modules, and the smaller contexts (`Tags`, `Templates`, `Scripts`, `PostLinks`) now have public `@spec` coverage. `BDS.SpecCoverageTest` scans the Section 10 module set and fails when a public function/arity lacks a matching spec.
**Convention reminder:** Ecto schemas need explicit `@type t`; use `term()` for associations; use `@typedoc` + named types for repeated map shapes; for attrs maps use `%{optional(atom()) => term(), optional(String.t()) => term()}`. **Convention reminder:** Ecto schemas need explicit `@type t`; use `term()` for associations; use `@typedoc` + named types for repeated map shapes; for attrs maps use `%{optional(atom()) => term(), optional(String.t()) => term()}`.
@@ -155,6 +155,8 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search`
### 2026-05-01 ### 2026-05-01
- **Missing `@spec`**: closed Section 10 by adding public specs to the remaining smaller contexts (`BDS.Tags`, `BDS.Templates`, `BDS.Scripts`, `BDS.PostLinks`) and LiveView editor modules under `BDS.Desktop.ShellLive.*Editor`. Added `BDS.Tags.Tag.t/0` to match the existing schema typing convention and introduced `BDS.SpecCoverageTest`, which scans the Section 10 module set for public function/arity entries without matching specs.
- **`BDS.Tasks` memory growth**: added configurable TTL eviction for terminal tasks in the `BDS.Tasks` GenServer (`:finished_task_ttl_ms`, default 1 h). Finished tasks are pruned by delayed GenServer cleanup and on task read calls; active tasks are preserved. Added regression coverage that completes a task with a tiny TTL and verifies it disappears without `clear_finished/0` while a running task remains. Section 13 is closed. - **`BDS.Tasks` memory growth**: added configurable TTL eviction for terminal tasks in the `BDS.Tasks` GenServer (`:finished_task_ttl_ms`, default 1 h). Finished tasks are pruned by delayed GenServer cleanup and on task read calls; active tasks are preserved. Added regression coverage that completes a task with a tiny TTL and verifies it disappears without `clear_finished/0` while a running task remains. Section 13 is closed.
- **Atom/string key duality**: added `BDS.MapUtils.attr/3` and a regression test that scans `lib/**/*.ex` and `lib/**/*.heex` for same-name atom/string `Map.get` fallback reads. Replaced same-name atom/string boundary reads across AI attrs, rendering assigns, pagination/archive contexts, UI command/filter params, metadata category settings, metadata-diff repair payloads, CLI sync payloads, chat tool call normalization, and misc editor duplicate/metadata-diff payload rendering. Remaining mixed-key scan hits are intentionally different-key fallbacks (for example camelCase/snake_case JSON compatibility) or atom-only/string-only boundaries. Section 12 is closed. - **Atom/string key duality**: added `BDS.MapUtils.attr/3` and a regression test that scans `lib/**/*.ex` and `lib/**/*.heex` for same-name atom/string `Map.get` fallback reads. Replaced same-name atom/string boundary reads across AI attrs, rendering assigns, pagination/archive contexts, UI command/filter params, metadata category settings, metadata-diff repair payloads, CLI sync payloads, chat tool call normalization, and misc editor duplicate/metadata-diff payload rendering. Remaining mixed-key scan hits are intentionally different-key fallbacks (for example camelCase/snake_case JSON compatibility) or atom-only/string-only boundaries. Section 12 is closed.

View File

@@ -17,7 +17,9 @@ defmodule BDS.AI.CatalogProvider do
def changeset(provider, attrs) do def changeset(provider, attrs) do
provider 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]) |> validate_required([:id, :name, :updated_at])
end end
end end

View File

@@ -25,7 +25,9 @@ defmodule BDS.AI.ChatConversation do
def changeset(conversation, attrs) do def changeset(conversation, attrs) do
conversation 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]) |> validate_required([:id, :title, :created_at, :updated_at])
end end
end end

View File

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

View File

@@ -14,8 +14,10 @@ defmodule BDS.AI.ChatTools do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
%{ %{
post_count: Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id), post_count:
media_count: Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id), 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), tag_count: Chat.count_distinct_string_list(Post, :tags, project_id),
category_count: Chat.count_distinct_string_list(Post, :categories, project_id) category_count: Chat.count_distinct_string_list(Post, :categories, project_id)
} }
@@ -132,9 +134,28 @@ defmodule BDS.AI.ChatTools do
project_tools = project_tools =
if is_binary(project_id) do 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: "blog_stats",
%{name: "list_media", spec: tool_spec("list_media", "List recent media items in the active project", limit_schema())} 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 else
[] []
@@ -142,14 +163,62 @@ defmodule BDS.AI.ChatTools do
project_tools ++ 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_card",
%{name: "render_chart", spec: tool_spec("render_chart", "Return a structured chart payload", render_chart_schema())}, spec:
%{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())}, tool_spec("render_card", "Return a structured card payload", render_card_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_table",
%{name: "render_mindmap", spec: tool_spec("render_mindmap", "Return a structured mindmap payload", render_mindmap_schema())} 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 else
[] []

View File

@@ -2,7 +2,11 @@ defmodule BDS.AI.HttpClient do
@moduledoc false @moduledoc false
def get(url, headers) when is_binary(url) and is_map(headers) do 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() :inets.start()
:ssl.start() :ssl.start()
@@ -24,7 +28,10 @@ defmodule BDS.AI.HttpClient do
def post(url, headers, body) def post(url, headers, body)
when is_binary(url) and is_map(headers) and is_binary(body) do when is_binary(url) and is_map(headers) and is_binary(body) do
request = 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() :inets.start()
:ssl.start() :ssl.start()

View File

@@ -34,31 +34,41 @@ defmodule BDS.AI.Model do
def changeset(model, attrs) do def changeset(model, attrs) do
model 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, :provider,
:model_id, :model_id,
:name, :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, :context_window,
:max_input_tokens, :max_input_tokens,
:max_output_tokens, :max_output_tokens,
:interleaved,
:status,
:updated_at :updated_at
], empty_values: [nil]) ])
|> validate_required([:provider, :model_id, :name, :context_window, :max_input_tokens, :max_output_tokens, :updated_at])
end end
end end

View File

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

View File

@@ -65,7 +65,9 @@ defmodule BDS.AI.Runtime do
end end
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do 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 end
defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do 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 defp fetch_endpoint_for_mode(mode, secret_backend) do
with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) do with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) do
case endpoint 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 if mode == :online and blank?(loaded.api_key) do
{:error, %{kind: :endpoint_not_configured, endpoint: mode}} {:error, %{kind: :endpoint_not_configured, endpoint: mode}}
else else

View File

@@ -17,7 +17,15 @@ defmodule BDS.AI.SecretBackend do
with {:ok, binary} <- Base.decode64(encoded), with {:ok, binary} <- Base.decode64(encoded),
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary, <<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
plaintext when is_binary(plaintext) <- 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} {:ok, plaintext}
else else
_other -> {:error, :invalid_ciphertext} _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 def put_setting(key, value) when is_binary(key) and is_binary(value) do
now = Persistence.now_ms() now = Persistence.now_ms()
(%Setting{} %Setting{}
|> Setting.changeset(%{key: key, value: value, updated_at: now})) |> Setting.changeset(%{key: key, value: value, updated_at: now})
|> Repo.insert( |> Repo.insert(
on_conflict: [set: [value: value, updated_at: now]], on_conflict: [set: [value: value, updated_at: now]],
conflict_target: [:key] conflict_target: [:key]

View File

@@ -39,12 +39,18 @@ defmodule BDS.CliSync do
ids = Enum.map(notifications, & &1.id) ids = Enum.map(notifications, & &1.id)
if ids != [] do 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 end
{:ok, {:ok,
Enum.map(notifications, fn notification -> 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)}
end end
@@ -52,13 +58,17 @@ defmodule BDS.CliSync do
{processed_count, _} = {processed_count, _} =
Repo.delete_all( Repo.delete_all(
from notification in Notification, 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, _} = {unprocessed_count, _} =
Repo.delete_all( Repo.delete_all(
from notification in Notification, 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}} {:ok, %{processed: processed_count, unprocessed: unprocessed_count}}

View File

@@ -15,7 +15,9 @@ defmodule BDS.CliSync.Notification do
def changeset(notification, attrs) do def changeset(notification, attrs) do
notification 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]) |> validate_required([:entity_type, :entity_id, :action, :from_cli, :created_at])
end end
end end

View File

@@ -24,7 +24,11 @@ defmodule BDS.CliSync.Watcher do
@impl true @impl true
def init(opts) do def init(opts) do
state = %{ 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) pubsub: Keyword.get(opts, :pubsub, BDS.PubSub)
} }
@@ -49,7 +53,11 @@ defmodule BDS.CliSync.Watcher do
{:ok, _pruned} = CliSync.prune_notifications() {:ok, _pruned} = CliSync.prune_notifications()
Enum.each(notifications, fn notification -> 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) end)
state state

View File

@@ -107,7 +107,9 @@ defmodule BDS.Desktop.Automation do
end end
def handle_call({:native_menu_action, action}, _from, state) do 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} {:reply, normalize_simple_reply(reply), state}
end end
@@ -204,7 +206,9 @@ defmodule BDS.Desktop.Automation do
receive_driver_message(state, @request_timeout, fn message -> receive_driver_message(state, @request_timeout, fn message ->
case message do 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} -> %{"ref" => ^ref, "status" => "error", "message" => reason} ->
raise "desktop automation request failed: #{reason}" raise "desktop automation request failed: #{reason}"
@@ -242,7 +246,8 @@ defmodule BDS.Desktop.Automation do
defp process_driver_messages(state, deadline, matcher) do defp process_driver_messages(state, deadline, matcher) do
{messages, buffer} = split_driver_buffer(state.driver_buffer) {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 case decode_driver_message(message) do
:skip -> :skip ->
{:cont, {acc, nil}} {:cont, {acc, nil}}
@@ -259,7 +264,11 @@ defmodule BDS.Desktop.Automation do
receive do receive do
{port, {:data, data}} when port == state.driver_port -> {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 -> {port, {:exit_status, status}} when port == state.driver_port ->
raise "desktop automation driver exited with status #{status}" 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 defp do_wait_for_server(base_url, deadline) do
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
{:ok, {{_, 200, _}, _headers, _body}} -> :ok {:ok, {{_, 200, _}, _headers, _body}} ->
:ok
_other -> _other ->
if System.monotonic_time(:millisecond) >= deadline do if System.monotonic_time(:millisecond) >= deadline do
raise "desktop app process did not become healthy in time" 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" signing_salt: "desktop-shell"
] ]
socket "/live", Phoenix.LiveView.Socket, socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
websocket: [connect_info: [session: @session_options]]
plug Plug.Session, @session_options plug(Plug.Session, @session_options)
plug :maybe_require_desktop_auth plug(:maybe_require_desktop_auth)
plug Plug.Static, plug(Plug.Static,
at: "/assets", at: "/assets",
from: {:bds, "priv/ui"}, from: {:bds, "priv/ui"},
only: ["app.css", "live.js", "monaco"] only: ["app.css", "live.js", "monaco"]
)
plug Plug.Static, plug(Plug.Static,
at: "/vendor/phoenix", at: "/vendor/phoenix",
from: {:phoenix, "priv/static"}, from: {:phoenix, "priv/static"},
only: ["phoenix.min.js"] only: ["phoenix.min.js"]
)
plug Plug.Static, plug(Plug.Static,
at: "/vendor/live_view", at: "/vendor/live_view",
from: {:phoenix_live_view, "priv/static"}, from: {:phoenix_live_view, "priv/static"},
only: ["phoenix_live_view.min.js"] only: ["phoenix_live_view.min.js"]
)
plug BDS.Desktop.Router plug(BDS.Desktop.Router)
defp maybe_require_desktop_auth(conn, _opts) do defp maybe_require_desktop_auth(conn, _opts) do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] 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() restored = restore_bounds()
{default_width, default_height} = Keyword.get(desktop_config, :window_size, @default_size) {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) {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 = [ base_opts = [
app: :bds, app: :bds,
@@ -70,7 +72,9 @@ defmodule BDS.Desktop.MainWindow do
frame -> frame ->
apply_restored_bounds(frame) apply_restored_bounds(frame)
schedule_persist() 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
end end
@@ -124,9 +128,15 @@ defmodule BDS.Desktop.MainWindow do
defp current_bounds(frame) do defp current_bounds(frame) do
with_wx_env(fn -> with_wx_env(fn ->
cond do cond do
not :wxWindow.isShown(frame) -> nil not :wxWindow.isShown(frame) ->
:wxTopLevelWindow.isFullScreen(frame) -> nil nil
:wxTopLevelWindow.isMaximized(frame) -> nil
:wxTopLevelWindow.isFullScreen(frame) ->
nil
:wxTopLevelWindow.isMaximized(frame) ->
nil
true -> true ->
{x, y} = :wxWindow.getPosition(frame) {x, y} = :wxWindow.getPosition(frame)
{width, height} = :wxWindow.getSize(frame) {width, height} = :wxWindow.getSize(frame)
@@ -160,7 +170,8 @@ defmodule BDS.Desktop.MainWindow do
end end
defp normalize_bounds(%{x: x, y: y, width: width, height: height}) 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}} {:ok, %{x: x, y: y, width: width, height: height}}
end end
@@ -180,7 +191,8 @@ defmodule BDS.Desktop.MainWindow do
desktop_config = Application.get_env(:bds, :desktop, []) desktop_config = Application.get_env(:bds, :desktop, [])
case Keyword.get(desktop_config, :window_client_area_override) do 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} %{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(), with %{} = project <- Projects.get_active_project(),
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id), %MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
true <- media.project_id == project.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), absolute_path = Path.join(Projects.project_data_dir(project), relative_path),
true <- File.exists?(absolute_path) do true <- File.exists?(absolute_path) do
{:ok, thumbnail_content_type(relative_path), absolute_path} {:ok, thumbnail_content_type(relative_path), absolute_path}
@@ -33,7 +34,8 @@ defmodule BDS.Desktop.MediaController do
end end
rescue rescue
error in [Exqlite.Error, DBConnection.OwnershipError] -> 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__ reraise error, __STACKTRACE__
end 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(%{kind: :gallery} = overlay), do: %{overlay | lightbox: nil}
def close_lightbox(overlay), do: overlay 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)) next_index = rem(lightbox.current_index + 1, length(images))
%{overlay | lightbox: lightbox_from_index(images, next_index)} %{overlay | lightbox: lightbox_from_index(images, next_index)}
end end
def lightbox_next(overlay), do: overlay 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)) next_index = rem(lightbox.current_index - 1 + length(images), length(images))
%{overlay | lightbox: lightbox_from_index(images, next_index)} %{overlay | lightbox: lightbox_from_index(images, next_index)}
end end

View File

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

View File

@@ -38,7 +38,8 @@ defmodule BDS.Desktop.ShellData do
Projects.shell_snapshot() Projects.shell_snapshot()
rescue rescue
error in [Exqlite.Error, DBConnection.OwnershipError] -> 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__ reraise error, __STACKTRACE__
end end
@@ -54,7 +55,8 @@ defmodule BDS.Desktop.ShellData do
Dashboard.snapshot(project_id) Dashboard.snapshot(project_id)
rescue rescue
error in [Exqlite.Error, DBConnection.OwnershipError] -> 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__ reraise error, __STACKTRACE__
end end
@@ -65,7 +67,8 @@ defmodule BDS.Desktop.ShellData do
Sidebar.view(project_id, view_id, params) Sidebar.view(project_id, view_id, params)
rescue rescue
error in [Exqlite.Error, DBConnection.OwnershipError] -> 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__ reraise error, __STACKTRACE__
end end
@@ -75,7 +78,10 @@ defmodule BDS.Desktop.ShellData do
def assistant_cards do def assistant_cards do
[ [
%{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."}, %{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."} %{label: "Desktop Runtime", text: "The app window is now served from LiveView state."}
] ]
end end
@@ -117,7 +123,8 @@ defmodule BDS.Desktop.ShellData do
end end
rescue rescue
error in [DBConnection.OwnershipError, Exqlite.Error] -> 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__ reraise error, __STACKTRACE__
end end
@@ -146,17 +153,38 @@ defmodule BDS.Desktop.ShellData do
def activity_icon(id) do def activity_icon(id) do
case to_string(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>) "posts" ->
"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>) ~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>)
"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>) "pages" ->
"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>) ~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>)
"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>) "media" ->
"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>) ~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>)
"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>) "scripts" ->
_other -> activity_icon("posts") ~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
end end
@@ -171,7 +199,10 @@ defmodule BDS.Desktop.ShellData do
def dashboard_post_count_label(count) do def dashboard_post_count_label(count) do
normalized_count = count || 0 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}) translate(key, %{count: normalized_count})
end end
@@ -188,7 +219,7 @@ defmodule BDS.Desktop.ShellData do
top_items top_items
|> Enum.map(fn item -> |> 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)}) Map.merge(item, %{font_size: font_size, color: normalize_dashboard_tag_color(item.color)})
end) end)
|> Enum.sort_by(&String.downcase(to_string(&1.tag || ""))) |> Enum.sort_by(&String.downcase(to_string(&1.tag || "")))
@@ -199,10 +230,11 @@ defmodule BDS.Desktop.ShellData do
declarations = declarations =
if item.color do if item.color do
declarations ++ [ declarations ++
"background-color: #{item.color};", [
"color: #{dashboard_contrast_color(item.color)};" "background-color: #{item.color};",
] "color: #{dashboard_contrast_color(item.color)};"
]
else else
declarations declarations
end end
@@ -225,9 +257,17 @@ defmodule BDS.Desktop.ShellData do
def route_label(route) do def route_label(route) do
case to_string(route) do case to_string(route) do
"git_log" -> "Git Log" "git_log" ->
"post_links" -> "Post Links" "Git Log"
other -> other |> String.replace("_", " ") |> String.split() |> Enum.map_join(" ", &String.capitalize/1)
"post_links" ->
"Post Links"
other ->
other
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end end
end end
@@ -255,7 +295,10 @@ defmodule BDS.Desktop.ShellData do
defp effective_ui_language(locale), do: locale 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, :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 maybe_add_panel_tab(tabs, _route, _tab), do: tabs
defp default_project_snapshot do defp default_project_snapshot do

View File

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

View File

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

View File

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

View File

@@ -14,9 +14,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
"render_tabs" "render_tabs"
]) ])
@spec render_tool?(term()) :: term()
def render_tool?(name) when is_binary(name), do: MapSet.member?(@render_tool_names, name) 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 def render_tool?(_name), do: false
@spec build_render_surfaces(term(), term(), term()) :: term()
def build_render_surfaces(tool_calls, message_id, assigns) do def build_render_surfaces(tool_calls, message_id, assigns) do
tool_calls tool_calls
|> Enum.with_index() |> Enum.with_index()
@@ -28,6 +31,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
end) end)
end end
@spec build_render_surface(term(), term(), term()) :: term()
def build_render_surface(%{name: name, arguments: arguments}, surface_id, assigns) do def build_render_surface(%{name: name, arguments: arguments}, surface_id, assigns) do
if MapSet.member?(@render_tool_names, name) do if MapSet.member?(@render_tool_names, name) do
do_build_render_surface(name, arguments || %{}, surface_id, assigns) do_build_render_surface(name, arguments || %{}, surface_id, assigns)
@@ -51,6 +55,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
end end
end end
@spec normalize_tool_surface(term()) :: term()
def normalize_tool_surface(_content), do: nil def normalize_tool_surface(_content), do: nil
defp do_build_render_surface("render_card", arguments, surface_id, _assigns) do 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), label: map_value(field, "label", key),
input_type: map_value(field, "inputType") || map_value(field, "input_type", "text"), input_type: map_value(field, "inputType") || map_value(field, "input_type", "text"),
placeholder: map_value(field, "placeholder"), 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", [])), options: decode_surface_options(map_value(field, "options", [])),
required?: truthy?(map_value(field, "required", false)) required?: truthy?(map_value(field, "required", false))
} }
@@ -161,8 +171,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
type: "form", type: "form",
title: map_value(arguments, "title"), title: map_value(arguments, "title"),
fields: fields, fields: fields,
submit_label: map_value(arguments, "submitLabel") || map_value(arguments, "submit_label", translated("chat.stop")), submit_label:
submit_action: map_value(arguments, "submitAction") || map_value(arguments, "submit_action", "submitForm") 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 end
@@ -181,7 +195,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|> List.wrap() |> List.wrap()
|> Enum.with_index() |> Enum.with_index()
|> Enum.map(fn {content, content_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)
} }
end) end)
@@ -203,11 +221,21 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
type = map_value(content, "type", "text") type = map_value(content, "type", "text")
case type do case type do
render_type when render_type in ["card", "chart", "form", "list", "metric", "mindmap", "table", "tabs"] -> render_type
do_build_render_surface("render_#{render_type}", Map.delete(content, "type"), surface_id, assigns) 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" -> "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 -> _other ->
%{id: surface_id, type: "json", raw: content} %{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 @tool_args_max_length 30
@spec tool_call_name(term()) :: term()
def tool_call_name(tool_call) when is_map(tool_call) do def tool_call_name(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :name) || "tool" BDS.MapUtils.attr(tool_call, :name) || "tool"
end end
@spec tool_call_arguments(term()) :: term()
def tool_call_arguments(tool_call) when is_map(tool_call) do def tool_call_arguments(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{} BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
end end
@@ -25,6 +27,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
end) end)
end end
@spec normalize_tool_calls(term()) :: term()
def normalize_tool_calls(_tool_calls), do: [] def normalize_tool_calls(_tool_calls), do: []
def tool_arguments_preview(arguments) when is_map(arguments) do def tool_arguments_preview(arguments) when is_map(arguments) do
@@ -33,6 +36,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|> Enum.join(", ") |> Enum.join(", ")
end end
@spec tool_arguments_preview(term()) :: term()
def tool_arguments_preview(_arguments), do: "" def tool_arguments_preview(_arguments), do: ""
def mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) 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)
end end
@spec mark_tool_call_completed(term(), term()) :: term()
def mark_tool_call_completed(entry, _tool_call_id), do: entry 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(nil), do: []
def tool_markers_from_events(%{tool_events: tool_events}) 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/*") embed_templates("code_entity_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
socket socket
|> assign(:script_editor, build_script(socket.assigns)) |> assign(:script_editor, build_script(socket.assigns))
|> assign(:template_editor, build_template(socket.assigns)) |> assign(:template_editor, build_template(socket.assigns))
end end
@spec update_script(term(), term(), term()) :: term()
def update_script(socket, params, reload) do def update_script(socket, params, reload) do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
@@ -27,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec save_script(term(), term(), term()) :: term()
def save_script(socket, reload, append_output) do def save_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
@@ -62,6 +65,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec check_script(term(), term(), term()) :: term()
def check_script(socket, reload, append_output) do def check_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
@@ -82,6 +86,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec run_script(term(), term(), term()) :: term()
def run_script(socket, reload, append_output) do def run_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
@@ -111,6 +116,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec delete_script(term(), term(), term()) :: term()
def delete_script(socket, reload, append_output) do def delete_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
@@ -124,6 +130,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec update_template(term(), term(), term()) :: term()
def update_template(socket, params, reload) do def update_template(socket, params, reload) do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
@@ -139,6 +146,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec save_template(term(), term(), term()) :: term()
def save_template(socket, reload, append_output) do def save_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
@@ -169,6 +177,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec validate_template(term(), term(), term()) :: term()
def validate_template(socket, reload, append_output) do def validate_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
@@ -195,6 +204,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec delete_template(term(), term(), term()) :: term()
def delete_template(socket, reload, append_output) do def delete_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
@@ -211,6 +221,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec build_script(term()) :: term()
def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do
case Scripts.get_script(script_id) do case Scripts.get_script(script_id) do
nil -> nil ->
@@ -236,6 +247,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
def build_script(_assigns), do: nil def build_script(_assigns), do: nil
@spec build_template(term()) :: term()
def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do
case Templates.get_template(template_id) do case Templates.get_template(template_id) do
nil -> nil ->
@@ -259,9 +271,11 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
def build_template(_assigns), do: nil def build_template(_assigns), do: nil
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec format_timestamp(term()) :: term()
def format_timestamp(nil), do: "" def format_timestamp(nil), do: ""
def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp) def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp)

View File

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

View File

@@ -4,20 +4,27 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata} alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData} alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
@spec change_definition(term(), term(), term()) :: term()
def change_definition(socket, params, reload) do def change_definition(socket, params, reload) do
with %{id: definition_id} <- socket.assigns.current_tab, 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) reload.(socket, socket.assigns.workbench)
else else
_other -> reload.(socket, socket.assigns.workbench) _other -> reload.(socket, socket.assigns.workbench)
end end
end end
@spec select_uploads_folder(term(), term(), term()) :: term()
def select_uploads_folder(socket, reload, append_output) do def select_uploads_folder(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab do with %{id: definition_id} <- socket.assigns.current_tab do
case FolderPicker.choose_directory(translated("importAnalysis.uploadsFolder")) do case FolderPicker.choose_directory(translated("importAnalysis.uploadsFolder")) do
{:ok, uploads_folder_path} -> {: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) reload.(socket, socket.assigns.workbench)
:cancel -> :cancel ->
@@ -33,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end end
end end
@spec select_and_analyze(term(), term(), term()) :: term()
def select_and_analyze(socket, reload, append_output) do def select_and_analyze(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab, with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id) do %{} = definition <- ImportDefinitions.get_definition(definition_id) do
@@ -50,9 +58,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
task = task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> 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 -> 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
) )
end) end)
@@ -70,8 +84,14 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
ref: task.ref 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(
|> Phoenix.Component.assign(:import_editor_execution_states, Map.delete(socket.assigns.import_editor_execution_states, definition_id)) :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) |> reload.(socket.assigns.workbench)
:cancel -> :cancel ->
@@ -87,32 +107,50 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end end
end end
@spec note_analysis_progress(term(), term(), term(), term(), term()) :: term()
def note_analysis_progress(socket, definition_id, step, detail, reload) do def note_analysis_progress(socket, definition_id, step, detail, reload) do
socket socket
|> Phoenix.Component.assign( |> Phoenix.Component.assign(
:import_editor_analysis_states, :import_editor_analysis_states,
Map.update(socket.assigns.import_editor_analysis_states, definition_id, default_analysis_state(), fn state -> Map.update(
state socket.assigns.import_editor_analysis_states,
|> Map.put(:loading, true) definition_id,
|> Map.put(:step, step) default_analysis_state(),
|> Map.put(:detail, detail) fn state ->
end) state
|> Map.put(:loading, true)
|> Map.put(:step, step)
|> Map.put(:detail, detail)
end
)
) )
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec finish_analysis(term(), term(), term(), term(), term()) :: term()
def finish_analysis(socket, ref, result, reload, append_output) do def finish_analysis(socket, ref, result, reload, append_output) do
case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do
nil -> nil ->
socket socket
definition_id -> 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 =
socket socket
|> Phoenix.Component.assign(:import_editor_analysis_task_refs, Map.delete(socket.assigns.import_editor_analysis_task_refs, ref)) |> Phoenix.Component.assign(
|> Phoenix.Component.assign(:import_editor_analysis_states, Map.delete(socket.assigns.import_editor_analysis_states, definition_id)) :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 case result do
{:ok, report} -> {:ok, report} ->
@@ -146,6 +184,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end end
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 def handle_analysis_task_down(socket, ref, message, reload, append_output) do
case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do
nil -> nil ->
@@ -153,13 +192,20 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
definition_id -> definition_id ->
socket socket
|> Phoenix.Component.assign(:import_editor_analysis_task_refs, Map.delete(socket.assigns.import_editor_analysis_task_refs, ref)) |> Phoenix.Component.assign(
|> Phoenix.Component.assign(:import_editor_analysis_states, Map.delete(socket.assigns.import_editor_analysis_states, definition_id)) :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") |> append_output.(translated("activity.import"), message, nil, "error")
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
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(nil), do: %{total: 0, tags: 0, posts: 0, media: 0, pages: 0}
def importable_counts(report) do 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, [])) pages = importable_entity_count(Map.get(report.items, :pages, []))
media = importable_entity_count(Map.get(report.items, :media, [])) 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 end
@spec importable_entity_count(term()) :: term()
def importable_entity_count(items) do def importable_entity_count(items) do
Enum.count(items || [], fn item -> 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)
end end
@spec detail_items(term(), term()) :: term()
def detail_items(nil, _bucket), do: [] def detail_items(nil, _bucket), do: []
def detail_items(report, bucket) do def detail_items(report, bucket) do
get_in(report, [:details, bucket]) || get_in(report, [:items, bucket]) || [] get_in(report, [:details, bucket]) || get_in(report, [:items, bucket]) || []
end end
@spec default_analysis_state() :: term()
def default_analysis_state do def default_analysis_state do
%{loading: false, step: nil, detail: nil, file_path: nil, ref: nil} %{loading: false, step: nil, detail: nil, file_path: nil, ref: nil}
end end
@spec default_sections() :: term()
def default_sections do def default_sections do
%{ %{
post_conflicts: true, post_conflicts: true,
@@ -203,18 +261,22 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
} }
end end
@spec default_author(term()) :: term()
def default_author(project_id) do def default_author(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
Map.get(metadata, :default_author) Map.get(metadata, :default_author)
end end
@spec suggested_definition_name(term()) :: term()
def suggested_definition_name(report) do def suggested_definition_name(report) do
get_in(report, [:site_info, :url]) || get_in(report, [:site_info, :title]) get_in(report, [:site_info, :url]) || get_in(report, [:site_info, :title])
end end
@spec maybe_put(term(), term(), term()) :: term()
def maybe_put(map, _key, nil), do: map def maybe_put(map, _key, nil), do: map
def maybe_put(map, key, value), do: Map.put(map, key, value) 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 def allow_repo_sandbox(pid) when is_pid(pid) do
if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do
try do try do
@@ -241,8 +303,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end end
end end
@spec translate_phase(term()) :: term()
def translate_phase(other), do: other 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, ""] defp present?(value), do: value not in [nil, ""]
end end

View File

@@ -3,18 +3,27 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ConflictResolution do
alias BDS.ImportDefinitions 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, with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id), %{} = definition <- ImportDefinitions.get_definition(definition_id),
%{} = report <- ImportDefinitions.decode_analysis_result(definition), %{} = report <- ImportDefinitions.decode_analysis_result(definition),
updated_report <- update_conflict_resolution(report, item_type, item_name, resolution), 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) reload.(socket, socket.assigns.workbench)
else else
_other -> reload.(socket, socket.assigns.workbench) _other -> reload.(socket, socket.assigns.workbench)
end end
end end
@spec update_conflict_resolution(term(), term(), term(), term()) :: term()
def update_conflict_resolution(report, item_type, item_name, resolution) do def update_conflict_resolution(report, item_type, item_name, resolution) do
report report
|> update_in([:conflicts], fn conflicts -> |> 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)) |> update_in([:details], &update_conflict_bucket(&1, item_type, item_name, resolution))
end 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(nil, _item_type, _item_name, _resolution), do: nil
def update_conflict_bucket(buckets, item_type, item_name, resolution) do 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 -> update_in(buckets, [bucket_key], fn items ->
Enum.map(items || [], fn item -> 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.ShellData
alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState
@spec execute_import(term(), term(), term()) :: term()
def execute_import(socket, reload, _append_output) do def execute_import(socket, reload, _append_output) do
with %{id: definition_id} <- socket.assigns.current_tab, with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id), %{} = definition <- ImportDefinitions.get_definition(definition_id),
@@ -24,7 +25,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
uploads_folder_path: definition.uploads_folder_path, uploads_folder_path: definition.uploads_folder_path,
default_author: default_author, default_author: default_author,
on_progress: fn phase, current, total, detail -> 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
) )
end) end)
@@ -50,7 +54,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: task.ref 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) |> reload.(socket.assigns.workbench)
end end
else else
@@ -58,6 +65,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end end
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 def note_execution_progress(socket, definition_id, phase, current, total, detail, reload) do
{detail_text, eta} = decompose_progress_detail(detail) {detail_text, eta} = decompose_progress_detail(detail)
translated_phase = translate_execution_phase(phase) translated_phase = translate_execution_phase(phase)
@@ -65,30 +73,44 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
socket socket
|> Phoenix.Component.assign( |> Phoenix.Component.assign(
:import_editor_execution_states, :import_editor_execution_states,
Map.update(socket.assigns.import_editor_execution_states, definition_id, default_execution_state(), fn state -> Map.update(
state socket.assigns.import_editor_execution_states,
|> Map.put(:is_executing, true) definition_id,
|> Map.put(:phase, translated_phase) default_execution_state(),
|> Map.put(:current, current) fn state ->
|> Map.put(:total, total) state
|> Map.put(:detail, detail_text) |> Map.put(:is_executing, true)
|> Map.put(:eta, eta) |> Map.put(:phase, translated_phase)
end) |> Map.put(:current, current)
|> Map.put(:total, total)
|> Map.put(:detail, detail_text)
|> Map.put(:eta, eta)
end
)
) )
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec finish_execution(term(), term(), term(), term(), term()) :: term()
def finish_execution(socket, ref, result, reload, append_output) do def finish_execution(socket, ref, result, reload, append_output) do
case Map.get(socket.assigns.import_editor_execution_task_refs, ref) do case Map.get(socket.assigns.import_editor_execution_task_refs, ref) do
nil -> nil ->
socket socket
definition_id -> 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 =
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 case result do
{:ok, execution_result} -> {:ok, execution_result} ->
@@ -106,7 +128,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: nil 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) |> reload.(socket.assigns.workbench)
{:error, %{message: message}} -> {:error, %{message: message}} ->
@@ -144,7 +171,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end end
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) message = inspect(reason)
case kind do case kind do
@@ -157,10 +186,18 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
socket socket
definition_id -> 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)
)
|> Phoenix.Component.assign( |> Phoenix.Component.assign(
:import_editor_execution_states, :import_editor_execution_states,
Map.put(socket.assigns.import_editor_execution_states, definition_id, %{ Map.put(socket.assigns.import_editor_execution_states, definition_id, %{
@@ -177,8 +214,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end end
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 def handle_task_down(socket, _kind, _ref, _reason, _reload, _append_output), do: socket
@spec default_execution_state() :: term()
def default_execution_state do def default_execution_state do
%{ %{
is_executing: false, is_executing: false,
@@ -195,6 +234,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
} }
end end
@spec execution_progress_width(term()) :: term()
def execution_progress_width(state) do def execution_progress_width(state) do
current = Map.get(state, :current, 0) current = Map.get(state, :current, 0)
total = Map.get(state, :total, 0) total = Map.get(state, :total, 0)
@@ -205,25 +245,36 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end end
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: 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} 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(nil), do: nil
def to_string_or_nil(value) when is_binary(value), do: value def to_string_or_nil(value) when is_binary(value), do: value
def to_string_or_nil(value), do: inspect(value) def to_string_or_nil(value), do: inspect(value)
@spec format_eta(term()) :: term()
def format_eta(nil), do: nil def format_eta(nil), do: nil
def format_eta(ms) when is_integer(ms) and ms >= 0 do def format_eta(ms) when is_integer(ms) and ms >= 0 do
seconds = div(ms, 1000) seconds = div(ms, 1000)
if seconds < 60 do if seconds < 60 do
translated("importAnalysis.eta", %{value: translated("importAnalysis.etaSeconds", %{count: seconds})}) translated("importAnalysis.eta", %{
value: translated("importAnalysis.etaSeconds", %{count: seconds})
})
else else
m = div(seconds, 60) m = div(seconds, 60)
s = rem(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
end end
@@ -240,7 +291,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end end
end end
@spec translate_execution_phase(term()) :: term()
def translate_execution_phase(other), do: other 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 end

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
alias BDS.{AI, ImportDefinitions, Metadata, Tags} alias BDS.{AI, ImportDefinitions, Metadata, Tags}
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
@spec start_taxonomy_edit(term(), term(), term()) :: term()
def start_taxonomy_edit( def start_taxonomy_edit(
socket, socket,
%{"type" => type, "name" => name, "mapped_to" => mapped_to}, %{"type" => type, "name" => name, "mapped_to" => mapped_to},
@@ -25,6 +26,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end end
end end
@spec cancel_taxonomy_edit(term(), term()) :: term()
def cancel_taxonomy_edit(socket, reload) do def cancel_taxonomy_edit(socket, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do with %{id: definition_id} <- socket.assigns.current_tab do
socket socket
@@ -38,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end end
end end
@spec save_taxonomy_edit(term(), term(), term()) :: term()
def save_taxonomy_edit( def save_taxonomy_edit(
socket, socket,
%{"type" => type, "name" => name, "mapped_to" => mapped_to}, %{"type" => type, "name" => name, "mapped_to" => mapped_to},
@@ -68,10 +71,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end end
end end
@spec clear_taxonomy_mapping(term(), term(), term()) :: term()
def clear_taxonomy_mapping(socket, %{"type" => type, "name" => name}, reload) do def clear_taxonomy_mapping(socket, %{"type" => type, "name" => name}, reload) do
save_taxonomy_edit(socket, %{"type" => type, "name" => name, "mapped_to" => ""}, reload) save_taxonomy_edit(socket, %{"type" => type, "name" => name, "mapped_to" => ""}, reload)
end end
@spec analyze_taxonomy_ai(term(), term(), term()) :: term()
def analyze_taxonomy_ai(socket, reload, append_output) do def analyze_taxonomy_ai(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab, with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id), %{} = definition <- ImportDefinitions.get_definition(definition_id),
@@ -142,6 +147,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end end
end end
@spec update_taxonomy_mapping(term(), term(), term(), term()) :: term()
def update_taxonomy_mapping(report, type, name, mapped_to) do def update_taxonomy_mapping(report, type, name, mapped_to) do
bucket_key = if(type == "categories", do: :categories, else: :tags) bucket_key = if(type == "categories", do: :categories, else: :tags)
normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil() normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil()
@@ -164,6 +170,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
) )
end end
@spec rebuild_taxonomy_stats(term()) :: term()
def rebuild_taxonomy_stats(items) do def rebuild_taxonomy_stats(items) do
%{ %{
existing_count: Enum.count(items, & &1.exists_in_project), existing_count: Enum.count(items, & &1.exists_in_project),
@@ -172,9 +179,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
} }
end end
@spec stat_key(term()) :: term()
def stat_key(:categories), do: :category_stats def stat_key(:categories), do: :category_stats
def stat_key(:tags), do: :tag_stats def stat_key(:tags), do: :tag_stats
@spec apply_taxonomy_mappings(term(), term()) :: term()
def apply_taxonomy_mappings(report, analysis) do def apply_taxonomy_mappings(report, analysis) do
report report
|> update_in( |> update_in(
@@ -198,6 +207,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end) end)
end end
@spec apply_taxonomy_mapping_bucket(term(), term()) :: term()
def apply_taxonomy_mapping_bucket(items, mappings) do def apply_taxonomy_mapping_bucket(items, mappings) do
Enum.map(items || [], fn item -> Enum.map(items || [], fn item ->
case Map.fetch(mappings, item.name) do case Map.fetch(mappings, item.name) do
@@ -207,6 +217,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end) end)
end end
@spec existing_taxonomy_terms(term()) :: term()
def existing_taxonomy_terms(project_id) do def existing_taxonomy_terms(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
@@ -216,6 +227,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
} }
end end
@spec normalize_taxonomy_mapping_value(term(), term(), term()) :: term()
def normalize_taxonomy_mapping_value(project_id, type, mapped_to) do def normalize_taxonomy_mapping_value(project_id, type, mapped_to) do
normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil() normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil()
@@ -231,6 +243,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end end
end end
@spec auto_mapped_count(term(), term()) :: term()
def auto_mapped_count(previous_report, next_report) do def auto_mapped_count(previous_report, next_report) do
previous_count = previous_count =
(Map.get(previous_report.items, :categories, []) ++ (Map.get(previous_report.items, :categories, []) ++
@@ -244,6 +257,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
max(next_count - previous_count, 0) max(next_count - previous_count, 0)
end end
@spec taxonomy_pill_class(term()) :: term()
def taxonomy_pill_class(item) do def taxonomy_pill_class(item) do
cond do cond do
item.exists_in_project -> "import-taxonomy-pill exists" item.exists_in_project -> "import-taxonomy-pill exists"
@@ -252,9 +266,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end end
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?(%{type: type, name: name}, type, name), do: true
def taxonomy_item_editing?(_edit, _type, _name), do: false def taxonomy_item_editing?(_edit, _type, _name), do: false
@spec taxonomy_mapping_tooltip(term()) :: term()
def taxonomy_mapping_tooltip(item) do def taxonomy_mapping_tooltip(item) do
action = action =
if present?(item.mapped_to), if present?(item.mapped_to),
@@ -264,6 +280,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
translated("importAnalysis.mappingTooltip", %{action: action}) translated("importAnalysis.mappingTooltip", %{action: action})
end end
@spec maybe_put_option(term(), term(), term()) :: term()
def maybe_put_option(opts, _key, nil), do: opts def maybe_put_option(opts, _key, nil), do: opts
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value) 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 end
defp maybe_set_sidebar_width(workbench, nil), do: workbench defp maybe_set_sidebar_width(workbench, nil), do: workbench
defp maybe_set_sidebar_width(workbench, width), defp maybe_set_sidebar_width(workbench, width),
do: Workbench.set_sidebar_width(workbench, parse_width(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.Repo
alias BDS.UI.Workbench alias BDS.UI.Workbench
embed_templates "media_editor_html/*" embed_templates("media_editor_html/*")
@post_picker_limit 10 @post_picker_limit 10
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
assign(socket, :media_editor, build(socket.assigns)) assign(socket, :media_editor, build(socket.assigns))
end end
@spec update(term(), term(), term()) :: term()
def update(socket, params, reload) do def update(socket, params, reload) do
case socket.assigns.current_tab do case socket.assigns.current_tab do
%{type: :media, id: media_id} -> %{type: :media, id: media_id} ->
@@ -38,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec persist_socket(term(), term(), term(), term()) :: term()
def persist_socket(socket, media_id, reload, append_output) do def persist_socket(socket, media_id, reload, append_output) do
case Media.get_media(media_id) do case Media.get_media(media_id) do
nil -> nil ->
@@ -52,9 +55,18 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id)) |> assign(
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved)) :media_editor_drafts,
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media))) 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) |> reload.(workbench)
{:error, reason} -> {:error, reason} ->
@@ -65,14 +77,19 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec toggle_quick_actions(term(), term(), term()) :: term()
def toggle_quick_actions(socket, media_id, reload) do def toggle_quick_actions(socket, media_id, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket 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) |> reload.(workbench)
end end
@spec replace_file(term(), term(), term(), term()) :: term()
def replace_file(socket, media_id, reload, append_output) do def replace_file(socket, media_id, reload, append_output) do
case FilePicker.choose_file(translated("Replace Media File")) do case FilePicker.choose_file(translated("Replace Media File")) do
{:ok, source_path} -> {:ok, source_path} ->
@@ -82,9 +99,18 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id)) |> assign(
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved)) :media_editor_drafts,
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media))) 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) |> reload.(workbench)
{:ok, nil} -> {:ok, nil} ->
@@ -106,10 +132,16 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec detect_language(term(), term(), term(), term()) :: term()
def detect_language(socket, media_id, reload, append_output) do def detect_language(socket, media_id, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) do
socket 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) |> reload.(socket.assigns.workbench)
else else
case Media.get_media(media_id) do case Media.get_media(media_id) do
@@ -118,15 +150,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
%MediaRecord{} = media -> %MediaRecord{} = media ->
draft = current_draft(socket.assigns, 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 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) normalized = normalize_language(language_code)
case Media.update_media(media.id, %{language: normalized}) do case Media.update_media(media.id, %{language: normalized}) do
{:ok, updated_media} -> {: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 socket
|> reconcile_draft(updated_media, updated_draft) |> reconcile_draft(updated_media, updated_draft)
@@ -145,17 +188,28 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
_other -> _other ->
socket 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) |> reload.(socket.assigns.workbench)
end end
end end
end end
end end
@spec translate(term(), term(), term(), term(), term()) :: term()
def translate(socket, media_id, language, reload, append_output) do def translate(socket, media_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) do
socket 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) |> reload.(socket.assigns.workbench)
else else
normalized_language = normalize_language(language) 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 case Media.upsert_media_translation(media_id, normalized_language, translation) do
{:ok, _saved_translation} -> {:ok, _saved_translation} ->
socket socket
|> assign(:media_editor_quick_actions_open, Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false)) |> assign(
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) :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) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
@@ -183,6 +243,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec apply_ai_suggestions(term(), term(), term(), term(), term()) :: term()
def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do
try do try do
case Media.get_media(media_id) do case Media.get_media(media_id) do
@@ -213,6 +274,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec delete_socket(term(), term(), term(), term()) :: term()
def delete_socket(socket, media_id, reload, append_output) do def delete_socket(socket, media_id, reload, append_output) do
case Media.delete_media(media_id) do case Media.delete_media(media_id) do
{:ok, :deleted} -> {:ok, :deleted} ->
@@ -223,11 +285,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|> assign(:shell_overlay, nil) |> assign(:shell_overlay, nil)
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id})) |> 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_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(
|> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id)) :media_editor_quick_actions_open,
|> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id)) Map.delete(socket.assigns.media_editor_quick_actions_open, 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_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) |> reload.(workbench)
{:error, reason} -> {:error, reason} ->
@@ -237,28 +314,43 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec toggle_post_picker(term(), term(), term()) :: term()
def toggle_post_picker(socket, media_id, reload) do def toggle_post_picker(socket, media_id, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket 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) |> reload.(workbench)
end end
@spec set_post_picker_query(term(), term(), term(), term()) :: term()
def set_post_picker_query(socket, media_id, query, reload) do def set_post_picker_query(socket, media_id, query, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket 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) |> reload.(workbench)
end end
@spec link_post(term(), term(), term(), term(), term()) :: term()
def link_post(socket, media_id, post_id, reload, append_output) do def link_post(socket, media_id, post_id, reload, append_output) do
case Media.link_media_to_post(media_id, post_id) do case Media.link_media_to_post(media_id, post_id) do
{:ok, _linked} -> {:ok, _linked} ->
socket socket
|> assign(:media_editor_post_pickers_open, Map.put(socket.assigns.media_editor_post_pickers_open, media_id, false)) |> assign(
|> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, "")) :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) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
@@ -268,6 +360,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec unlink_post(term(), term(), term(), term(), term()) :: term()
def unlink_post(socket, media_id, post_id, reload, append_output) do def unlink_post(socket, media_id, post_id, reload, append_output) do
case Media.unlink_media_from_post(media_id, post_id) do case Media.unlink_media_from_post(media_id, post_id) do
{:ok, _unlinked} -> {:ok, _unlinked} ->
@@ -280,6 +373,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec edit_translation(term(), term(), term(), term()) :: term()
def edit_translation(socket, media_id, language, reload) do def edit_translation(socket, media_id, language, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
@@ -287,16 +381,20 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
form = %{ form = %{
"language" => language, "language" => language,
"title" => translation && translation.title || "", "title" => (translation && translation.title) || "",
"alt" => translation && translation.alt || "", "alt" => (translation && translation.alt) || "",
"caption" => translation && translation.caption || "" "caption" => (translation && translation.caption) || ""
} }
socket 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) |> reload.(workbench)
end end
@spec update_translation(term(), term(), term(), term()) :: term()
def update_translation(socket, media_id, params, reload) do def update_translation(socket, media_id, params, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
@@ -308,10 +406,14 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
} }
socket 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) |> reload.(workbench)
end end
@spec save_translation(term(), term(), term(), term()) :: term()
def save_translation(socket, media_id, reload, append_output) do def save_translation(socket, media_id, reload, append_output) do
case Map.get(socket.assigns.media_editor_translation_forms, media_id) do case Map.get(socket.assigns.media_editor_translation_forms, media_id) do
%{"language" => language} = form when language not in [nil, ""] -> %{"language" => language} = form when language not in [nil, ""] ->
@@ -322,7 +424,10 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
}) do }) do
{:ok, _translation} -> {:ok, _translation} ->
socket 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) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
@@ -336,16 +441,23 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec refresh_translation(term(), term(), term(), term(), term()) :: term()
def refresh_translation(socket, media_id, language, reload, append_output) do def refresh_translation(socket, media_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) do
socket 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) |> reload.(socket.assigns.workbench)
else else
case AI.translate_media(media_id, normalize_language(language)) do case AI.translate_media(media_id, normalize_language(language)) do
{:ok, translation} -> {:ok, translation} ->
case Media.upsert_media_translation(media_id, language, translation) do 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} -> {:error, reason} ->
socket socket
@@ -361,11 +473,15 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec delete_translation(term(), term(), term(), term(), term()) :: term()
def delete_translation(socket, media_id, language, reload, append_output) do def delete_translation(socket, media_id, language, reload, append_output) do
case Media.delete_media_translation(media_id, language) do case Media.delete_media_translation(media_id, language) do
{:ok, _deleted?} -> {:ok, _deleted?} ->
socket 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) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
@@ -375,6 +491,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do
case Media.get_media(media_id) do case Media.get_media(media_id) do
nil -> nil ->
@@ -385,7 +502,9 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
translations = Media.list_media_translations(media.id) translations = Media.list_media_translations(media.id)
form = current_draft(assigns, media) form = current_draft(assigns, media)
picker_query = Map.get(assigns.media_editor_post_picker_queries, media.id, "") 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, id: media.id,
@@ -416,20 +535,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
def build(_assigns), do: nil 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(:dirty), do: translated("Unsaved")
def media_editor_save_state_label(:saved), do: translated("Saved") def media_editor_save_state_label(:saved), do: translated("Saved")
def media_editor_save_state_label(_state), do: translated("Idle") def media_editor_save_state_label(_state), do: translated("Idle")
@spec language_label(term()) :: term()
def language_label(code) do def language_label(code) do
code code
|> to_string() |> to_string()
|> String.upcase() |> String.upcase()
end end
@spec normalize_language(term()) :: term()
def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase() def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase()
@spec persist(term(), term()) :: term()
def persist(%MediaRecord{} = media, draft) do def persist(%MediaRecord{} = media, draft) do
Media.update_media(media.id, %{ Media.update_media(media.id, %{
title: blank_to_nil(Map.get(draft, "title")), 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 defp reconcile_draft(socket, %MediaRecord{} = media, draft) do
persisted = persisted_form(media) persisted = persisted_form(media)
dirty? = draft != persisted 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 = drafts =
if dirty? do if dirty? do
@@ -456,8 +585,21 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:media_editor_drafts, drafts) |> 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(
|> 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 || ""})) :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 end
defp current_draft(assigns, %MediaRecord{} = media) do defp current_draft(assigns, %MediaRecord{} = media) do
@@ -505,10 +647,15 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
from post in Post, from post in Post,
where: post.project_id == ^media.project_id, where: post.project_id == ^media.project_id,
order_by: [desc: post.updated_at, desc: post.created_at], 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.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)} {Enum.take(posts, @post_picker_limit), max(length(posts) - @post_picker_limit, 0)}
end end
@@ -518,18 +665,28 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
defp preview_url(%MediaRecord{} = media) do 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 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 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) and size >= 1_048_576,
defp format_file_size(size) when is_integer(size), do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB" 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 format_file_size(_size), do: "0.0 KB"
defp detect_language_enabled?(form) do defp detect_language_enabled?(form) do
@@ -567,5 +724,6 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
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 end

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
use Phoenix.Component use Phoenix.Component
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.MenuEditor.{ alias BDS.Desktop.ShellLive.MenuEditor.{
DraftManagement, DraftManagement,
PageCategory, PageCategory,
@@ -12,8 +13,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
TreePredicates TreePredicates
} }
embed_templates "menu_editor_html/*" embed_templates("menu_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
case socket.assigns[:current_tab] do case socket.assigns[:current_tab] do
%{type: :menu_editor, id: tab_id} -> %{type: :menu_editor, id: tab_id} ->
@@ -36,12 +38,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec select_item(term(), term(), term()) :: term()
def select_item(socket, item_id, reload) do def select_item(socket, item_id, reload) do
socket socket
|> State.update_state(fn state -> %{state | selected_id: item_id} end) |> State.update_state(fn state -> %{state | selected_id: item_id} end)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec change_entry(term(), term(), term()) :: term()
def change_entry(socket, params, reload) do def change_entry(socket, params, reload) do
query = Map.get(params, "query", "") query = Map.get(params, "query", "")
@@ -50,6 +54,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec submit_entry(term(), term()) :: term()
def submit_entry(socket, reload) do def submit_entry(socket, reload) do
case DraftManagement.current_draft(socket.assigns) do case DraftManagement.current_draft(socket.assigns) do
%{type: :page} -> %{type: :page} ->
@@ -67,12 +72,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec cancel_entry(term(), term()) :: term()
def cancel_entry(socket, reload) do def cancel_entry(socket, reload) do
socket socket
|> State.update_state(&DraftManagement.cancel_draft/1) |> State.update_state(&DraftManagement.cancel_draft/1)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec select_page(term(), term(), term()) :: term()
def select_page(socket, post_id, reload) do def select_page(socket, post_id, reload) do
case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do
nil -> nil ->
@@ -85,6 +92,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec select_category(term(), term(), term()) :: term()
def select_category(socket, name, reload) do def select_category(socket, name, reload) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
@@ -99,6 +107,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec toolbar_action(term(), term(), term(), term()) :: term()
def toolbar_action(socket, action, reload, append_output) do def toolbar_action(socket, action, reload, append_output) do
case action do case action do
"add-entry" -> "add-entry" ->
@@ -144,12 +153,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec drop_item(term(), term(), term(), term(), term()) :: term()
def drop_item(socket, drag_item_id, target_item_id, position, reload) do def drop_item(socket, drag_item_id, target_item_id, position, reload) do
socket socket
|> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position)) |> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec handle_keydown(term(), term(), term()) :: term()
def handle_keydown(socket, "Escape", reload) do def handle_keydown(socket, "Escape", reload) do
cancel_entry(socket, reload) cancel_entry(socket, reload)
end end
@@ -158,14 +169,16 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
reload.(socket, socket.assigns.workbench) reload.(socket, socket.assigns.workbench)
end end
attr :menu_editor, :map, required: true attr(:menu_editor, :map, required: true)
@spec menu_editor(term()) :: term()
def menu_editor(assigns) def menu_editor(assigns)
attr :items, :list, required: true attr(:items, :list, required: true)
attr :menu_editor, :map, required: true attr(:menu_editor, :map, required: true)
attr :depth, :integer, required: true attr(:depth, :integer, required: true)
@spec menu_tree_level(term()) :: term()
def menu_tree_level(assigns) do def menu_tree_level(assigns) do
~H""" ~H"""
<%= for item <- @items do %> <%= for item <- @items do %>
@@ -289,8 +302,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
""" """
end end
attr :kind, :atom, required: true attr(:kind, :atom, required: true)
@spec kind_icon(term()) :: term()
def kind_icon(assigns) do def kind_icon(assigns) do
~H""" ~H"""
<%= case @kind do %> <%= case @kind do %>
@@ -306,9 +320,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
""" """
end end
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec row_label(term(), term()) :: term()
def row_label(item, category_titles) do def row_label(item, category_titles) do
if item.kind == :category_archive do if item.kind == :category_archive do
Map.get(category_titles || %{}, item.slug, item.label) Map.get(category_titles || %{}, item.slug, item.label)
@@ -317,6 +333,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec kind_label(term()) :: term()
def kind_label(:home), do: translated("menuEditor.type.home") def kind_label(:home), do: translated("menuEditor.type.home")
def kind_label(:page), do: translated("menuEditor.type.page") def kind_label(:page), do: translated("menuEditor.type.page")
def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive") 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 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(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive")
def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title") 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(%{draft: %{type: :category}}), do: translated("menuEditor.categoryPicker.hint")
def editing_hint(_menu_editor), do: translated("menuEditor.createHint") 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") def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder")
end 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.PageCategory
alias BDS.Desktop.ShellLive.MenuEditor.TreeOps alias BDS.Desktop.ShellLive.MenuEditor.TreeOps
@spec current_draft(term()) :: term()
def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft) def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft)
@spec start_page_draft(term()) :: term()
def start_page_draft(state) do def start_page_draft(state) do
item = %{ item = %{
item_id: Ecto.UUID.generate(), item_id: Ecto.UUID.generate(),
@@ -29,6 +31,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
} }
end end
@spec start_category_draft(term()) :: term()
def start_category_draft(state) do def start_category_draft(state) do
item = %{ item = %{
item_id: Ecto.UUID.generate(), item_id: Ecto.UUID.generate(),
@@ -50,6 +53,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
} }
end end
@spec finalize_submenu_draft(term()) :: term()
def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do
label = label =
if(String.trim(query) == "", if(String.trim(query) == "",
@@ -69,12 +73,19 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def finalize_submenu_draft(state), do: state 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 def assign_page_to_draft(%{draft: %{item_id: item_id}} = state, post) do
%{ %{
state state
| items: | items:
TreeOps.update_item(state.items, item_id, fn item -> 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), end),
draft: nil draft: nil
} }
@@ -82,6 +93,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def assign_page_to_draft(state, _post), do: state 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 def assign_category_to_draft(%{draft: %{item_id: item_id}} = state, category) do
label = PageCategory.blank_to_nil(category.title) || category.name 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 def assign_category_to_draft(state, _category), do: state
@spec cancel_draft(term()) :: term()
def cancel_draft(%{draft: %{item_id: item_id}} = state) do def cancel_draft(%{draft: %{item_id: item_id}} = state) do
items = TreeOps.remove_item(state.items, item_id) items = TreeOps.remove_item(state.items, item_id)
%{state | items: items, selected_id: TreeOps.first_item_id(items), draft: nil} %{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 def cancel_draft(state), do: state
@spec confirm_category_draft(term(), term()) :: term()
def confirm_category_draft(socket, update_state_fun) do def confirm_category_draft(socket, update_state_fun) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
draft = current_draft(socket.assigns) draft = current_draft(socket.assigns)
@@ -117,8 +131,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
category = category =
cond do cond do
category != nil -> category category != nil ->
normalized == "" -> %{name: "", title: ""} category
normalized == "" ->
%{name: "", title: ""}
true -> true ->
{:ok, _metadata} = Metadata.add_category(project_id, normalized) {:ok, _metadata} = Metadata.add_category(project_id, normalized)
%{name: normalized, title: normalized} %{name: normalized, title: normalized}

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,10 +20,12 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
:git_diff :git_diff
] ]
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
assign(socket, :misc_editor, build(socket.assigns)) assign(socket, :misc_editor, build(socket.assigns))
end end
@spec rerun(term()) :: term()
def rerun(socket) do def rerun(socket) do
case meta(socket.assigns) do case meta(socket.assigns) do
%{action: action} when is_binary(action) -> %{action: action} when is_binary(action) ->
@@ -37,6 +39,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec apply_site_validation(term(), term()) :: term()
def apply_site_validation(socket, append_output) do def apply_site_validation(socket, append_output) do
meta = meta(socket.assigns) meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{}) 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")} append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")}
end end
@spec toggle_duplicate(term(), term(), term()) :: term()
def toggle_duplicate(socket, pair_id, reload) do def toggle_duplicate(socket, pair_id, reload) do
selected_by_tab = Map.get(socket.assigns, :misc_editor_selected_pairs, %{}) selected_by_tab = Map.get(socket.assigns, :misc_editor_selected_pairs, %{})
current = Map.get(selected_by_tab, socket.assigns.current_tab.id, MapSet.new()) 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) |> reload.(socket.assigns.workbench)
end end
@spec dismiss_duplicate(term(), term(), term(), term(), term()) :: term()
def dismiss_duplicate(socket, post_id_a, post_id_b, reload, append_output) do 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 case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do
{:ok, _saved_pair} -> {:ok, _saved_pair} ->
@@ -109,6 +114,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec dismiss_selected(term(), term(), term()) :: term()
def dismiss_selected(socket, reload, append_output) do def dismiss_selected(socket, reload, append_output) do
tab_id = socket.assigns.current_tab.id tab_id = socket.assigns.current_tab.id
@@ -141,6 +147,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec fix_translation_validation(term(), term()) :: term()
def fix_translation_validation(socket, append_output) do def fix_translation_validation(socket, append_output) do
report = report =
socket.assigns socket.assigns
@@ -166,6 +173,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")} append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")}
end end
@spec select_git_diff_file(term(), term()) :: term()
def select_git_diff_file(socket, file_path) do def select_git_diff_file(socket, file_path) do
assign( assign(
socket, socket,
@@ -178,6 +186,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
) )
end end
@spec metadata_diff_repair_request(term(), term(), term()) :: term()
def metadata_diff_repair_request(socket, field, direction) do def metadata_diff_repair_request(socket, field, direction) do
meta = meta(socket.assigns) meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{}) payload = Map.get(meta, :payload, %{})
@@ -209,6 +218,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec metadata_diff_orphan_import_request(term()) :: term()
def metadata_diff_orphan_import_request(socket) do def metadata_diff_orphan_import_request(socket) do
meta = meta(socket.assigns) meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{}) payload = Map.get(meta, :payload, %{})
@@ -232,6 +242,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec build(term()) :: term()
def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do
meta = meta(assigns) meta = meta(assigns)
payload = Map.get(meta, :payload, %{}) payload = Map.get(meta, :payload, %{})
@@ -245,11 +256,14 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec build(term()) :: term()
def build(_assigns), do: nil def build(_assigns), do: nil
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) 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(:site_validation), do: "site-validation-view"
def misc_class(:metadata_diff), do: "metadata-diff-view" def misc_class(:metadata_diff), do: "metadata-diff-view"
def misc_class(:translation_validation), do: "translation-validation-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 misc_class(:git_diff), do: "git-diff-view"
def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary) def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary)
@spec summary_items(term()) :: term()
def summary_items(_misc), do: [] 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) 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) def pair_id_from_pair(pair), do: pair_identity(pair)
defp build_site_validation(meta, payload) do defp build_site_validation(meta, payload) do
@@ -410,6 +427,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
} }
end end
@spec translation_issue_label(term()) :: term()
def translation_issue_label(issue) do def translation_issue_label(issue) do
case issue_value(issue, :issue) do case issue_value(issue, :issue) do
"same-language-as-canonical" -> "same-language-as-canonical" ->
@@ -426,6 +444,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec translation_issue_languages(term()) :: term()
def translation_issue_languages(issue) do def translation_issue_languages(issue) do
canonical_language = issue_value(issue, :canonical_language) canonical_language = issue_value(issue, :canonical_language)
translation_language = issue_value(issue, :translation_language) translation_language = issue_value(issue, :translation_language)
@@ -440,8 +459,10 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec translation_issue_value(term(), term()) :: term()
def translation_issue_value(issue, key), do: issue_value(issue, key) 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(nil), do: "plaintext"
def git_diff_language(file_path) do 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.Posts.{Post, PostMedia, Translation}
alias BDS.Tags.Tag alias BDS.Tags.Tag
embed_templates "overlay_html/*" embed_templates("overlay_html/*")
def context(assigns, tab_title, tab_subtitle) do def context(assigns, tab_title, tab_subtitle) do
project_id = assigns.projects.active_project_id project_id = assigns.projects.active_project_id
@@ -23,7 +23,12 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
media = media(project_id) 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_post_language: source_language(current_tab, metadata),
current_media_language: source_language(current_tab, metadata), current_media_language: source_language(current_tab, metadata),
posts: posts, posts: posts,
@@ -59,7 +64,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
def markdown_link(text, url), do: "[#{text}](#{url})" 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: []} def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
@@ -77,7 +83,15 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
from post in Post, from post in Post,
where: post.project_id == ^project_id, where: post.project_id == ^project_id,
order_by: [desc: post.updated_at, desc: post.created_at], 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 -> |> Enum.map(fn post ->
%{ %{
@@ -96,7 +110,14 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
from media in MediaRecord, from media in MediaRecord,
where: media.project_id == ^project_id, where: media.project_id == ^project_id,
order_by: [desc: media.updated_at, desc: media.created_at], 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 -> |> Enum.map(fn media ->
%{ %{
@@ -149,7 +170,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
defp existing_translations(_tab), do: %{} defp existing_translations(_tab), do: %{}
defp blog_languages(metadata) 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.reject(&is_nil/1)
|> Enum.uniq() |> Enum.uniq()
end end
@@ -193,9 +215,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
%Post{} = post -> %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: "title",
%{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} 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 -> _other ->
@@ -209,9 +249,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
case Media.get_media(media_id) do case Media.get_media(media_id) do
%MediaRecord{} = media -> %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: "title",
%{key: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", suggested_value: refine_excerpt(title, media.caption || title), locked: false} 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 -> _other ->
@@ -248,7 +306,13 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: reference_list reference_list: reference_list
} }
rescue 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 end
defp delete_details(%{type: :tags}, page_language) do defp delete_details(%{type: :tags}, page_language) do
@@ -263,16 +327,33 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: [] reference_list: []
} }
rescue 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 end
defp delete_details(_tab, page_language) do 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 end
defp merge_details(project_id, page_language) do defp merge_details(project_id, page_language) do
tags = 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" target = List.first(tags) || "tag"
@@ -283,7 +364,13 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
message: ShellData.translate("Cannot be undone.", %{}, page_language) message: ShellData.translate("Cannot be undone.", %{}, page_language)
} }
rescue 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 end
defp canonical_post_url(post) do defp canonical_post_url(post) do
@@ -302,7 +389,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
if base == "", do: "#{title} overview", else: base <> "." if base == "", do: "#{title} overview", else: base <> "."
end 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 defp slugify(value) do
value value

View File

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

View File

@@ -74,13 +74,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
defdelegate tag_chip_style(color), to: ListValues 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 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)) assign(socket, :post_editor, build(assigns))
end end
@spec update(term(), term(), term()) :: term()
def update(socket, params, reload) do def update(socket, params, reload) do
case socket.assigns.current_tab do case socket.assigns.current_tab do
%{type: :post, id: post_id} -> %{type: :post, id: post_id} ->
@@ -91,7 +99,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) 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) requested_language = normalize_language(Map.get(params, "language"), current_language)
next_language = next_language =
@@ -117,6 +128,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec persist_socket(term(), term(), term(), term(), term()) :: term()
def persist_socket(socket, post_id, action, reload, append_output) do def persist_socket(socket, post_id, action, reload, append_output) do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -125,7 +137,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) 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) draft = current_draft(socket.assigns, post, metadata, active_language)
case persist(post, draft, active_language, metadata, action) do case persist(post, draft, active_language, metadata, action) do
@@ -135,9 +150,30 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, normalized_form)) |> assign(
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, save_state_for_action(action))) :post_editor_drafts,
|> 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))})) 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) |> reload.(workbench)
{:error, reason} -> {:error, reason} ->
@@ -148,6 +184,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec discard_socket(term(), term(), term(), term()) :: term()
def discard_socket(socket, post_id, reload, append_output) do def discard_socket(socket, post_id, reload, append_output) do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -156,7 +193,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) 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 case discard(post, active_language, metadata) do
{:ok, restored_post} -> {:ok, restored_post} ->
@@ -164,9 +203,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, active_language)) |> assign(
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :discarded)) :post_editor_drafts,
|> 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)})) 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) |> reload.(workbench)
{:error, reason} -> {:error, reason} ->
@@ -177,6 +228,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec delete_socket(term(), term(), term(), term()) :: term()
def delete_socket(socket, post_id, reload, append_output) do def delete_socket(socket, post_id, reload, append_output) do
case Posts.delete_post(post_id) do case Posts.delete_post(post_id) do
{:ok, :deleted} -> {:ok, :deleted} ->
@@ -185,13 +237,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket socket
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id})) |> 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_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(
|> assign(:post_editor_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id)) :post_editor_active_languages,
|> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id)) Map.delete(socket.assigns.post_editor_active_languages, post_id)
|> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, 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_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_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) |> reload.(workbench)
{:error, reason} -> {:error, reason} ->
@@ -201,6 +268,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec set_mode(term(), term(), term(), term()) :: term()
def set_mode(socket, post_id, mode, reload) do def set_mode(socket, post_id, mode, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
normalized_mode = normalize_mode(mode) normalized_mode = normalize_mode(mode)
@@ -216,38 +284,67 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
socket 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) |> reload.(workbench)
end end
@spec toggle_section(term(), term(), term(), term()) :: term()
def toggle_section(socket, post_id, section, reload) when section in [:metadata, :excerpt] do def toggle_section(socket, post_id, section, reload) when section in [:metadata, :excerpt] do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket 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) |> reload.(workbench)
end end
@spec select_language(term(), term(), term(), term()) :: term()
def select_language(socket, post_id, language, reload) do def select_language(socket, post_id, language, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket 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) |> reload.(workbench)
end end
@spec toggle_quick_actions(term(), term(), term()) :: term()
def toggle_quick_actions(socket, post_id, reload) do def toggle_quick_actions(socket, post_id, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket 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) |> reload.(workbench)
end end
@spec detect_language(term(), term(), term(), term()) :: term()
def detect_language(socket, post_id, reload, append_output) do def detect_language(socket, post_id, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) do
socket 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) |> reload.(socket.assigns.workbench)
else else
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
@@ -257,14 +354,24 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) 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) draft = current_draft(socket.assigns, post, metadata, active_language)
text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "content", "")], "\n\n") text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "content", "")], "\n\n")
case AI.detect_language(text) do 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 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) |> reload_with_assigned_workbench(reload)
{:error, reason} -> {:error, reason} ->
@@ -274,17 +381,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
_other -> _other ->
socket 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) |> reload.(socket.assigns.workbench)
end end
end end
end end
end end
@spec translate(term(), term(), term(), term(), term()) :: term()
def translate(socket, post_id, language, reload, append_output) do def translate(socket, post_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) do
socket 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) |> reload.(socket.assigns.workbench)
else else
normalized_language = normalize_language(language, "") normalized_language = normalize_language(language, "")
@@ -298,9 +416,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
content: translation.content content: translation.content
}) do }) do
socket socket
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, normalized_language)) |> assign(
|> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, normalized_language)) :post_editor_active_languages,
|> assign(:post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false)) 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) |> reload.(socket.assigns.workbench)
else else
{:error, reason} -> {:error, reason} ->
@@ -317,6 +444,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec apply_ai_suggestions(term(), term(), term(), term(), term()) :: term()
def apply_ai_suggestions(socket, post_id, fields, reload, append_output) do def apply_ai_suggestions(socket, post_id, fields, reload, append_output) do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -340,12 +468,30 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
case Posts.update_post(post_id, attrs) do case Posts.update_post(post_id, attrs) do
{:ok, updated_post} -> {:ok, updated_post} ->
metadata = project_metadata(updated_post.project_id) 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) refreshed_form = persisted_form(updated_post, metadata, active_language)
socket socket
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, refreshed_form)) |> assign(
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) :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) |> assign(:shell_overlay, nil)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
@@ -358,6 +504,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec insert_content(term(), term(), term(), term()) :: term()
def insert_content(socket, post_id, snippet, reload) do def insert_content(socket, post_id, snippet, reload) do
socket socket
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{id: post_id, content: snippet}) |> 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) |> reload.(socket.assigns.workbench)
end 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 def add_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -373,7 +521,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) 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) draft = current_draft(socket.assigns, post, metadata, active_language)
normalized = normalize_list_entry(value) normalized = normalize_list_entry(value)
@@ -398,6 +549,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
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 def remove_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -406,9 +558,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) 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) 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 socket
|> put_draft_field(post_id, post, active_language, field_key(kind), updated) |> put_draft_field(post_id, post, active_language, field_key(kind), updated)
@@ -416,6 +577,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -424,7 +586,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = assigned_project_metadata(assigns) metadata = assigned_project_metadata(assigns)
canonical_language = canonical_language(post, metadata) 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) translations = translations(post.id)
persisted = DraftManagement.persisted_form(post, metadata, active_language, translations) 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), metadata_expanded: Map.get(expanded, :metadata, false),
excerpt_expanded: Map.get(expanded, :excerpt, false), excerpt_expanded: Map.get(expanded, :excerpt, false),
mode: Map.get(assigns.post_editor_modes, post.id, :markdown), 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_publish?: post.status == :draft,
can_delete?: post.status == :published, can_delete?: post.status == :published,
has_published_version?: has_published_version?(post), has_published_version?: has_published_version?(post),
discard_label: discard_label(post), discard_label: discard_label(post),
discard_title: discard_title(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)), can_translate?: Enum.any?(languages(metadata), &(&1 != canonical_language)),
languages: languages(metadata), languages: languages(metadata),
form: form, form: form,
@@ -469,16 +636,45 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
tag_values: tag_values(form), tag_values: tag_values(form),
tag_chips: tag_chips(form, Tags.list_tags(post.project_id)), tag_chips: tag_chips(form, Tags.list_tags(post.project_id)),
tag_query: query_value(assigns, :tags, post.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_values: category_values(form),
category_query: query_value(assigns, :categories, post.id), category_query: query_value(assigns, :categories, post.id),
category_options: metadata.categories || [], category_options: metadata.categories || [],
category_query_addable?: query_addable?(query_value(assigns, :categories, post.id), category_values(form), metadata.categories || [], & &1), category_query_addable?:
tag_suggestions: tag_suggestions(form, Tags.list_tags(post.project_id), query_value(assigns, :tags, post.id)), query_addable?(
category_suggestions: category_suggestions(form, metadata.categories || [], query_value(assigns, :categories, post.id)), 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), gallery_count: gallery_count(form),
preview_url: preview_url(post, active_language, canonical_language, Map.get(assigns.post_editor_modes, post.id, :markdown)), preview_url:
translation_flags: translation_flags(post, canonical_language, active_language, translations), 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), linked_media: linked_media(post.id),
post_links: post_links(post.id), post_links: post_links(post.id),
footer: footer(post, current_translation, active_language, canonical_language) footer: footer(post, current_translation, active_language, canonical_language)
@@ -488,17 +684,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
def build(_assigns), do: nil def build(_assigns), do: nil
@spec post_status_label(term()) :: term()
def post_status_label(status), do: ShellData.dashboard_status_label(status) 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(:dirty), do: translated("Unsaved")
def post_editor_save_state_label(:saved), do: translated("Saved") 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(:published), do: translated("Published")
def post_editor_save_state_label(:discarded), do: translated("Reverted") def post_editor_save_state_label(:discarded), do: translated("Reverted")
def post_editor_save_state_label(_state), do: translated("Idle") 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(:markdown), do: translated("Markdown")
def post_editor_mode_label(:preview), do: translated("Preview") def post_editor_mode_label(:preview), do: translated("Preview")
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) 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.Desktop.ShellLive.PostEditor.PostMetadata
alias BDS.UI.Workbench alias BDS.UI.Workbench
@spec normalize_mode(term()) :: term()
def normalize_mode(mode) when mode in [:markdown, :preview], do: mode 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("visual"), do: :markdown
def normalize_mode("preview"), do: :preview def normalize_mode("preview"), do: :preview
def normalize_mode(_mode), do: :markdown def normalize_mode(_mode), do: :markdown
@spec normalize_language(term(), term()) :: term()
def normalize_language(value, fallback) do def normalize_language(value, fallback) do
case value |> to_string() |> String.trim() do case value |> to_string() |> String.trim() do
"" -> fallback "" -> fallback
@@ -20,6 +23,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
end end
end end
@spec normalize_params(term(), term(), term()) :: term()
def normalize_params(params, current_language, next_language) do def normalize_params(params, current_language, next_language) do
%{ %{
"title" => Map.get(params, "title", ""), "title" => Map.get(params, "title", ""),
@@ -28,12 +32,17 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
"tags" => Map.get(params, "tags", ""), "tags" => Map.get(params, "tags", ""),
"categories" => Map.get(params, "categories", ""), "categories" => Map.get(params, "categories", ""),
"author" => Map.get(params, "author", ""), "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")), "do_not_translate" => truthy?(Map.get(params, "do_not_translate")),
"template_slug" => Map.get(params, "template_slug", "") "template_slug" => Map.get(params, "template_slug", "")
} }
end end
@spec current_draft(term(), term(), term(), term()) :: term()
def current_draft(assigns, %Post{} = post, metadata, active_language) do def current_draft(assigns, %Post{} = post, metadata, active_language) do
persisted = persisted_form(post, metadata, active_language) persisted = persisted_form(post, metadata, active_language)
@@ -42,10 +51,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
|> Map.get(active_language, persisted) |> Map.get(active_language, persisted)
end end
@spec persisted_form(term(), term(), term()) :: term()
def persisted_form(%Post{} = post, metadata, active_language) do def persisted_form(%Post{} = post, metadata, active_language) do
persisted_form(post, metadata, active_language, PostMetadata.translations(post.id)) persisted_form(post, metadata, active_language, PostMetadata.translations(post.id))
end end
@spec persisted_form(term(), term(), term(), term()) :: term()
def persisted_form(post, metadata, active_language, translations) do def persisted_form(post, metadata, active_language, translations) do
canonical_language = PostMetadata.canonical_language(post, metadata) canonical_language = PostMetadata.canonical_language(post, metadata)
translation = Map.get(translations, active_language) translation = Map.get(translations, active_language)
@@ -64,8 +75,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
} }
else else
%{ %{
"title" => translation && translation.title || "", "title" => (translation && translation.title) || "",
"excerpt" => translation && translation.excerpt || "", "excerpt" => (translation && translation.excerpt) || "",
"content" => if(translation, do: Posts.editor_body(translation), else: ""), "content" => if(translation, do: Posts.editor_body(translation), else: ""),
"tags" => Enum.join(post.tags || [], ", "), "tags" => Enum.join(post.tags || [], ", "),
"categories" => Enum.join(post.categories || [], ", "), "categories" => Enum.join(post.categories || [], ", "),
@@ -77,22 +88,43 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
end end
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 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) workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)) |> assign(
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)) :post_editor_drafts,
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)
|> 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_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) |> maybe_drop_old_language_draft(post_id, current_language, next_language)
end end
def maybe_update_draft(socket, post_id, _post, _current_language, next_language, _draft, false) do 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 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 def put_draft_field(socket, post_id, post, active_language, field, value) do
metadata = PostMetadata.project_metadata(post.project_id) metadata = PostMetadata.project_metadata(post.project_id)
draft = Map.put(current_draft(socket.assigns, post, metadata, active_language), field, value) 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 socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, draft)) |> assign(
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) :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 end
@spec put_query_state(term(), term(), term(), term()) :: term()
def put_query_state(socket, post_id, kind, value) do def put_query_state(socket, post_id, kind, value) do
key = query_key(kind) 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 end
@spec query_value(term(), term(), term()) :: term()
def query_value(assigns, kind, post_id) do def query_value(assigns, kind, post_id) do
assigns assigns
|> Map.get(query_key(kind), %{}) |> 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(:tags), do: :post_editor_tag_queries
defp query_key(:categories), do: :post_editor_category_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, defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language)
do: socket when current_language == next_language,
do: socket
defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do 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 end
@spec toggled_sections(term(), term(), term()) :: term()
def toggled_sections(expanded_by_post, post_id, section) do def toggled_sections(expanded_by_post, post_id, section) do
expanded_by_post expanded_by_post
|> Map.get(post_id, %{metadata: false, excerpt: false}) |> Map.get(post_id, %{metadata: false, excerpt: false})
|> Map.put_new(:metadata, false) |> Map.put_new(:metadata, false)
|> Map.put_new(:excerpt, false) |> Map.put_new(:excerpt, false)
|> Map.update!(section, &not &1) |> Map.update!(section, &(not &1))
end end
@spec put_nested_map(term(), term(), term(), term()) :: term()
def put_nested_map(map, key, nested_key, value) do def put_nested_map(map, key, nested_key, value) do
Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value)) Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value))
end end
@spec delete_nested_map(term(), term(), term()) :: term()
def delete_nested_map(map, key, nested_key) do def delete_nested_map(map, key, nested_key) do
case Map.get(map, key) do case Map.get(map, key) do
nil -> nil ->
@@ -150,20 +203,26 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
end end
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(:publish), do: :published
def save_state_for_action(_action), do: :saved def save_state_for_action(_action), do: :saved
@spec record_title(term(), term()) :: term()
def record_title(%Translation{title: title}, post), def record_title(%Translation{title: title}, post),
do: blank_to_nil(title) || post.title || post.slug || post.id do: blank_to_nil(title) || post.title || post.slug || post.id
def record_title(%Post{title: title, slug: slug, id: id}, _post), def record_title(%Post{title: title, slug: slug, id: id}, _post),
do: blank_to_nil(title) || blank_to_nil(slug) || id 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(%Translation{status: status}), do: status || :draft
def record_status(%Post{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 def editing_canonical_language?(translations, active_language, canonical_language) do
active_language == canonical_language or not Map.has_key?(translations, active_language) active_language == canonical_language or not Map.has_key?(translations, active_language)
end end

View File

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

View File

@@ -6,11 +6,16 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, PostMetadata} 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 def persist(%Post{} = post, draft, active_language, metadata, action) do
canonical_language = PostMetadata.canonical_language(post, metadata) canonical_language = PostMetadata.canonical_language(post, metadata)
translations = PostMetadata.translations(post.id) 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 post
|> save_canonical_draft(draft) |> save_canonical_draft(draft)
|> maybe_publish_post(post.id, action) |> maybe_publish_post(post.id, action)
@@ -21,12 +26,17 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
end end
end end
@spec discard(term(), term(), term()) :: term()
def discard(%Post{} = post, active_language, metadata) do def discard(%Post{} = post, active_language, metadata) do
canonical_language = PostMetadata.canonical_language(post, metadata) canonical_language = PostMetadata.canonical_language(post, metadata)
current_translations = PostMetadata.translations(post.id) current_translations = PostMetadata.translations(post.id)
cond do 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} {:ok, post}
post.file_path not in [nil, ""] and post.status == :draft -> post.file_path not in [nil, ""] and post.status == :draft ->
@@ -37,15 +47,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
end end
end end
@spec has_published_version?(term()) :: term()
def has_published_version?(%Post{} = post), def has_published_version?(%Post{} = post),
do: not is_nil(post.published_at) or post.file_path not in [nil, ""] 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 def discard_label(%Post{} = post) do
if has_published_version?(post), if has_published_version?(post),
do: translated("Discard Changes"), do: translated("Discard Changes"),
else: translated("Discard Draft") else: translated("Discard Draft")
end end
@spec discard_title(term()) :: term()
def discard_title(%Post{} = post) do def discard_title(%Post{} = post) do
if has_published_version?(post), if has_published_version?(post),
do: translated("Discard changes and restore the published version"), 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.Media.Media, as: MediaRecord
alias BDS.Posts.{Post, PostMedia} alias BDS.Posts.{Post, PostMedia}
@spec project_metadata(term()) :: term()
def project_metadata(nil), do: %{main_language: "en", blog_languages: []} def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
def project_metadata(project_id) do def project_metadata(project_id) do
@@ -17,6 +18,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
_error -> %{main_language: "en", blog_languages: []} _error -> %{main_language: "en", blog_languages: []}
end end
@spec canonical_language(term(), term()) :: term()
def canonical_language(post, metadata) do def canonical_language(post, metadata) do
BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language( BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language(
post.language, post.language,
@@ -24,28 +26,36 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
) )
end end
@spec translations(term()) :: term()
def translations(post_id) do def translations(post_id) do
{:ok, translations} = Posts.list_post_translations(post_id) {:ok, translations} = Posts.list_post_translations(post_id)
Map.new(translations, fn translation -> {translation.language, translation} end) Map.new(translations, fn translation -> {translation.language, translation} end)
end end
@spec languages(term()) :: term()
def languages(metadata) do 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.reject(&is_nil/1)
|> Enum.uniq() |> Enum.uniq()
end end
@spec template_options(term()) :: term()
def template_options(project_id) do def template_options(project_id) do
Repo.all( Repo.all(
from template in Templates.Template, from template in Templates.Template,
where: template.project_id == ^project_id, where: template.project_id == ^project_id,
order_by: [asc: template.title, asc: template.slug], 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 rescue
_error -> [] _error -> []
end end
@spec linked_media(term()) :: term()
def linked_media(post_id) do def linked_media(post_id) do
rows = rows =
Repo.all( Repo.all(
@@ -74,6 +84,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
_error -> [] _error -> []
end end
@spec post_links(term()) :: term()
def post_links(post_id) do def post_links(post_id) do
%{ %{
backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id), 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 defp related_posts(links, key) do
Enum.map(links, fn link -> Enum.map(links, fn link ->
case Posts.get_post(Map.fetch!(link, key)) do 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} %Post{} = post ->
_other -> nil %{
id: post.id,
title: post.title || post.slug || post.id,
text: link.link_text || post.slug || post.id
}
_other ->
nil
end end
end) end)
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
end end
@spec translation_flags(term(), term(), term(), term()) :: term()
def translation_flags(post, canonical_language, active_language, translations) do 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 = others =
translations translations
@@ -111,6 +136,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
[canonical | others] [canonical | others]
end end
@spec footer(term(), term(), term(), term()) :: term()
def footer(post, translation, active_language, canonical_language) do def footer(post, translation, active_language, canonical_language) do
if active_language == canonical_language do if active_language == canonical_language do
%{ %{
@@ -120,8 +146,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
} }
else else
%{ %{
created_at: format_timestamp(translation && translation.created_at || post.created_at), created_at: format_timestamp((translation && translation.created_at) || post.created_at),
updated_at: format_timestamp(translation && translation.updated_at || post.updated_at), updated_at: format_timestamp((translation && translation.updated_at) || post.updated_at),
published_at: format_timestamp(translation && translation.published_at) published_at: format_timestamp(translation && translation.published_at)
} }
end end
@@ -135,10 +161,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> Calendar.strftime("%x") |> Calendar.strftime("%x")
end end
@spec display_title(term(), term(), term()) :: term()
def display_title(title, slug, fallback_id) do def display_title(title, slug, fallback_id) do
blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled") blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled")
end end
@spec gallery_count(term()) :: term()
def gallery_count(form) do def gallery_count(form) do
form form
|> Map.get("content", "") |> Map.get("content", "")
@@ -147,8 +175,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> length() |> length()
end 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 def preview_url(%Post{} = post, active_language, canonical_language, :preview) do
query = query =
%{} %{}
@@ -156,7 +187,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> maybe_put_query("post_id", post.id) |> maybe_put_query("post_id", post.id)
|> maybe_put_query("lang", active_language != canonical_language && active_language) |> 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 end
defp canonical_preview_path(created_at_ms, slug) do 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) 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 def truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false def truthy?(_value), do: false
@spec blank?(term()) :: term()
def blank?(value), do: blank_to_nil(value) == nil def blank?(value), do: blank_to_nil(value) == nil
@spec blank_to_nil(term()) :: term()
def blank_to_nil(value) do def blank_to_nil(value) do
value value
|> to_string() |> to_string()

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,10 +14,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
"page" => %{title: "page", render_in_lists: false, show_title: true} "page" => %{title: "page", render_in_lists: false, show_title: true}
} }
@spec protected_categories() :: term()
def protected_categories, do: @protected_categories def protected_categories, do: @protected_categories
@spec protected_category?(term()) :: term()
def protected_category?(category), do: MapSet.member?(@protected_categories, category) def protected_category?(category), do: MapSet.member?(@protected_categories, category)
@spec category_rows(term()) :: term()
def category_rows(metadata) do def category_rows(metadata) do
categories = Map.get(metadata, :categories, []) categories = Map.get(metadata, :categories, [])
settings = Map.get(metadata, :category_settings, %{}) settings = Map.get(metadata, :category_settings, %{})
@@ -37,12 +40,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end) end)
end end
@spec update_new_category(term(), term(), term()) :: term()
def update_new_category(socket, name, reload) do def update_new_category(socket, name, reload) do
socket socket
|> assign(:settings_editor_new_category, to_string(name || "")) |> assign(:settings_editor_new_category, to_string(name || ""))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec add_category(term(), term(), term()) :: term()
def add_category(socket, reload, append_output) do def add_category(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
name = socket.assigns[:settings_editor_new_category] |> to_string() |> String.trim() name = socket.assigns[:settings_editor_new_category] |> to_string() |> String.trim()
@@ -73,11 +78,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end end
end end
@spec reset_categories(term(), term(), term()) :: term()
def reset_categories(socket, reload, append_output) do def reset_categories(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
result = 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 if MapSet.member?(@protected_categories, category) do
{:cont, :ok} {:cont, :ok}
else else
@@ -102,6 +109,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end end
end end
@spec save_category(term(), term(), term(), term()) :: term()
def save_category(socket, params, reload, append_output) do def save_category(socket, params, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
category = Map.get(params, "category", "") category = Map.get(params, "category", "")
@@ -125,6 +133,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end end
end end
@spec remove_category(term(), term(), term(), term()) :: term()
def remove_category(socket, category, reload, append_output) do def remove_category(socket, category, reload, append_output) do
project_id = socket.assigns.projects.active_project_id 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} %{id: :openai_codex, label: "OpenAI Codex", supported?: false}
] ]
@spec mcp_rows() :: term()
def mcp_rows do def mcp_rows do
Enum.map(@mcp_agents, fn agent -> Enum.map(@mcp_agents, fn agent ->
%{ %{
@@ -28,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
end) end)
end end
@spec toggle_mcp_agent(term(), term(), term(), term()) :: term()
def toggle_mcp_agent(socket, agent, reload, append_output) do def toggle_mcp_agent(socket, agent, reload, append_output) do
case find_mcp_agent(agent) do case find_mcp_agent(agent) do
%{id: agent_id, supported?: true} = config -> %{id: agent_id, supported?: true} = config ->

View File

@@ -6,12 +6,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
alias BDS.Metadata alias BDS.Metadata
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
@spec project_metadata(term()) :: term()
def project_metadata(assigns) do def project_metadata(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} -> metadata {:ok, metadata} -> metadata
end end
end end
@spec project_form(term()) :: term()
def project_form(metadata) do def project_form(metadata) do
%{ %{
"name" => Map.get(metadata, :name, ""), "name" => Map.get(metadata, :name, ""),
@@ -28,18 +30,21 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
} }
end end
@spec technology_form(term()) :: term()
def technology_form(project_form) do def technology_form(project_form) do
%{ %{
"semantic_similarity_enabled" => Map.get(project_form, "semantic_similarity_enabled", false) "semantic_similarity_enabled" => Map.get(project_form, "semantic_similarity_enabled", false)
} }
end end
@spec update_project_draft(term(), term(), term()) :: term()
def update_project_draft(socket, params, reload) do def update_project_draft(socket, params, reload) do
socket socket
|> assign(:settings_editor_project_draft, normalize_project_params(params)) |> assign(:settings_editor_project_draft, normalize_project_params(params))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec save_project(term(), term(), term()) :: term()
def save_project(socket, reload, append_output) do def save_project(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id 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.Metadata
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
@spec publishing_form(term()) :: term()
def publishing_form(metadata) do def publishing_form(metadata) do
prefs = Map.get(metadata, :publishing_preferences, %{}) prefs = Map.get(metadata, :publishing_preferences, %{})
@@ -17,12 +18,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
} }
end end
@spec update_publishing_draft(term(), term(), term()) :: term()
def update_publishing_draft(socket, params, reload) do def update_publishing_draft(socket, params, reload) do
socket socket
|> assign(:settings_editor_publishing_draft, normalize_publishing_params(params)) |> assign(:settings_editor_publishing_draft, normalize_publishing_params(params))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec save_publishing(term(), term(), term()) :: term()
def save_publishing(socket, reload, append_output) do def save_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
@@ -39,6 +42,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
end end
end end
@spec clear_publishing(term(), term(), term()) :: term()
def clear_publishing(socket, reload, append_output) do def clear_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id

View File

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

View File

@@ -22,7 +22,13 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
end end
def create(socket, project_id, "post", callbacks) do 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} -> {:ok, _post} ->
callbacks.reload.(socket, socket.assigns.workbench) callbacks.reload.(socket, socket.assigns.workbench)
@@ -42,7 +48,12 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:error, reason} -> {:error, reason} ->
socket 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) |> callbacks.reload.(socket.assigns.workbench)
end end
@@ -68,13 +79,23 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:ok, script} -> {:ok, script} ->
callbacks.open_sidebar.( callbacks.open_sidebar.(
socket, socket,
%{"route" => "scripts", "id" => script.id, "title" => script.title, "subtitle" => "Automation helpers"}, %{
"route" => "scripts",
"id" => script.id,
"title" => script.title,
"subtitle" => "Automation helpers"
},
:pin :pin
) )
{:error, reason} -> {:error, reason} ->
socket 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) |> callbacks.reload.(socket.assigns.workbench)
end end
end end
@@ -90,29 +111,52 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:ok, template} -> {:ok, template} ->
callbacks.open_sidebar.( callbacks.open_sidebar.(
socket, socket,
%{"route" => "templates", "id" => template.id, "title" => template.title, "subtitle" => "Site rendering"}, %{
"route" => "templates",
"id" => template.id,
"title" => template.title,
"subtitle" => "Site rendering"
},
:pin :pin
) )
{:error, reason} -> {:error, reason} ->
socket 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) |> callbacks.reload.(socket.assigns.workbench)
end end
end end
def create(socket, project_id, "import", callbacks) do 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} -> {:ok, definition} ->
callbacks.open_sidebar.( callbacks.open_sidebar.(
socket, socket,
%{"route" => "import", "id" => definition.id, "title" => definition.name, "subtitle" => "Import definitions"}, %{
"route" => "import",
"id" => definition.id,
"title" => definition.name,
"subtitle" => "Import definitions"
},
:pin :pin
) )
{:error, reason} -> {:error, reason} ->
socket 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) |> callbacks.reload.(socket.assigns.workbench)
end end
end end

View File

@@ -7,13 +7,17 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
if is_map(filters) and Map.get(filters, :enabled) do if is_map(filters) and Map.get(filters, :enabled) do
panel_state = filter_panel_state(socket, view_id) panel_state = filter_panel_state(socket, view_id)
Map.put(sidebar_data, :filters, Map.merge(filters, %{ Map.put(
filter_panel_visible: panel_state.visible, sidebar_data,
archive_collapsed: panel_state.archive_collapsed, :filters,
tags_collapsed: panel_state.tags_collapsed, Map.merge(filters, %{
categories_collapsed: panel_state.categories_collapsed, filter_panel_visible: panel_state.visible,
expanded_year: panel_state.expanded_year 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 else
sidebar_data sidebar_data
end end
@@ -22,7 +26,12 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
def put_filter_panel_state(socket, updater) do def put_filter_panel_state(socket, updater) do
view_id = Atom.to_string(socket.assigns.workbench.active_view) view_id = Atom.to_string(socket.assigns.workbench.active_view)
state = socket |> filter_panel_state(view_id) |> updater.() 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 end
def current_filters(socket, view_id) do def current_filters(socket, view_id) do
@@ -33,8 +42,17 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
def put_filters(socket, updater) do def put_filters(socket, updater) do
view_id = Atom.to_string(socket.assigns.workbench.active_view) 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 end
def toggle_filter_value(filters, key, value) do 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.Tags.Tag
alias BDS.Templates.Template alias BDS.Templates.Template
embed_templates "tags_editor_html/*" embed_templates("tags_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
assign(socket, :tags_editor, build(socket.assigns)) assign(socket, :tags_editor, build(socket.assigns))
end end
@spec toggle_selection(term(), term(), term()) :: term()
def toggle_selection(socket, tag_name, reload) do def toggle_selection(socket, tag_name, reload) do
selected = Map.get(socket.assigns, :tags_editor_selected, []) selected = Map.get(socket.assigns, :tags_editor_selected, [])
@@ -33,6 +35,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec update_new_tag(term(), term(), term()) :: term()
def update_new_tag(socket, params, reload) do def update_new_tag(socket, params, reload) do
socket socket
|> assign(:tags_editor_new_tag, %{ |> assign(:tags_editor_new_tag, %{
@@ -42,11 +45,16 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec create_tag(term(), term(), term()) :: term()
def create_tag(socket, reload, append_output) do def create_tag(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
draft = Map.get(socket.assigns, :tags_editor_new_tag, %{}) 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} -> {:ok, _tag} ->
socket socket
|> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""}) |> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""})
@@ -59,6 +67,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end end
end end
@spec update_edit_tag(term(), term(), term()) :: term()
def update_edit_tag(socket, params, reload) do def update_edit_tag(socket, params, reload) do
socket socket
|> assign(:tags_editor_edit_draft, %{ |> assign(:tags_editor_edit_draft, %{
@@ -69,16 +78,26 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec save_tag(term(), term(), term()) :: term()
def save_tag(socket, reload, append_output) do def save_tag(socket, reload, append_output) do
selected = Map.get(socket.assigns, :tags_editor_selected, []) selected = Map.get(socket.assigns, :tags_editor_selected, [])
draft = Map.get(socket.assigns, :tags_editor_edit_draft, %{}) draft = Map.get(socket.assigns, :tags_editor_edit_draft, %{})
case selected do case selected do
[tag_name] -> [tag_name] ->
case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do case Repo.get_by(Tag,
nil -> reload.(socket, socket.assigns.workbench) project_id: socket.assigns.projects.active_project_id,
name: tag_name
) do
nil ->
reload.(socket, socket.assigns.workbench)
%Tag{} = tag -> %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 {:ok, renamed_tag} <- maybe_rename_tag(tag, Map.get(draft, "name", tag.name)) do
socket socket
|> assign(:tags_editor_selected, [renamed_tag.name]) |> assign(:tags_editor_selected, [renamed_tag.name])
@@ -92,15 +111,22 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end end
end end
_other -> reload.(socket, socket.assigns.workbench) _other ->
reload.(socket, socket.assigns.workbench)
end end
end end
@spec delete_selected(term(), term(), term()) :: term()
def delete_selected(socket, reload, append_output) do def delete_selected(socket, reload, append_output) do
case Map.get(socket.assigns, :tags_editor_selected, []) do case Map.get(socket.assigns, :tags_editor_selected, []) do
[tag_name] -> [tag_name] ->
case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do case Repo.get_by(Tag,
nil -> reload.(socket, socket.assigns.workbench) project_id: socket.assigns.projects.active_project_id,
name: tag_name
) do
nil ->
reload.(socket, socket.assigns.workbench)
%Tag{} = tag -> %Tag{} = tag ->
case Tags.delete_tag(tag.id) do case Tags.delete_tag(tag.id) do
{:ok, _deleted} -> {:ok, _deleted} ->
@@ -116,16 +142,19 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end end
end end
_other -> reload.(socket, socket.assigns.workbench) _other ->
reload.(socket, socket.assigns.workbench)
end end
end end
@spec update_merge_target(term(), term(), term()) :: term()
def update_merge_target(socket, target, reload) do def update_merge_target(socket, target, reload) do
socket socket
|> assign(:tags_editor_merge_target, to_string(target || "")) |> assign(:tags_editor_merge_target, to_string(target || ""))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec merge_selected(term(), term(), term()) :: term()
def merge_selected(socket, reload, append_output) do def merge_selected(socket, reload, append_output) do
selected = Map.get(socket.assigns, :tags_editor_selected, []) selected = Map.get(socket.assigns, :tags_editor_selected, [])
target_name = Map.get(socket.assigns, :tags_editor_merge_target, "") target_name = Map.get(socket.assigns, :tags_editor_merge_target, "")
@@ -136,12 +165,19 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
true -> true ->
project_id = socket.assigns.projects.active_project_id 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)) target = Enum.find(tags, &(&1.name == target_name))
sources = Enum.reject(tags, &(&1.name == target_name)) sources = Enum.reject(tags, &(&1.name == target_name))
case target do case target do
nil -> reload.(socket, socket.assigns.workbench) nil ->
reload.(socket, socket.assigns.workbench)
_target -> _target ->
case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do
{:ok, _merged} -> {:ok, _merged} ->
@@ -160,23 +196,41 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end end
end end
@spec sync(term(), term(), term()) :: term()
def sync(socket, reload, append_output) do def sync(socket, reload, append_output) do
_ = append_output _ = append_output
:ok = Tags.sync_tags_json(socket.assigns.projects.active_project_id) :ok = Tags.sync_tags_json(socket.assigns.projects.active_project_id)
reload.(socket, socket.assigns.workbench) reload.(socket, socket.assigns.workbench)
end end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :tags}} = assigns) do def build(%{current_tab: %{type: :tags}} = assigns) do
project_id = assigns.projects.active_project_id 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) counts = tag_counts(project_id)
selected = Map.get(assigns, :tags_editor_selected, []) 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)) 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, selected: selected,
new_tag: Map.get(assigns, :tags_editor_new_tag, %{"name" => "", "color" => ""}), new_tag: Map.get(assigns, :tags_editor_new_tag, %{"name" => "", "color" => ""}),
edit_draft: edit_draft, edit_draft: edit_draft,
@@ -187,14 +241,18 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
def build(_assigns), do: nil 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 def tag_font_size(count, counts) do
max_count = Enum.max([1 | Enum.map(counts, & &1.count)]) 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) 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) Float.round(0.85 + (1.8 - 0.85) * ratio, 2)
end end
@spec tag_style(term(), term()) :: term()
def tag_style(tag, counts) do def tag_style(tag, counts) do
size = tag_font_size(tag.count, counts) 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 maybe_seed_edit_draft(socket, _selected), do: assign(socket, :tags_editor_edit_draft, %{})
defp edit_draft(nil), do: %{} 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 defp maybe_rename_tag(%Tag{} = tag, next_name) do
normalized = String.trim(to_string(next_name || tag.name)) normalized = String.trim(to_string(next_name || tag.name))
@@ -237,6 +301,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end end
defp blank_to_nil(nil), do: nil defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do defp blank_to_nil(value) do
case String.trim(to_string(value)) do case String.trim(to_string(value)) do
"" -> nil "" -> 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(:message, localize_task_message(Map.get(task, :message), locale))
|> Map.put(:group_name, localize_task_group(Map.get(task, :group_name), 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(: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 end
defp localize_task_message(nil, _locale), do: nil 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 @spec active_group(map()) :: map() | nil
def active_group(assigns) do 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 end
@spec active_items(map()) :: [map()] @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 Handle a keydown event on an open titlebar menu. `invoke_fun` is called
with the action id (string) when the user activates an item. 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() Phoenix.LiveView.Socket.t()
def handle_keydown(socket, key, invoke_fun) do def handle_keydown(socket, key, invoke_fun) do
if socket.assigns.titlebar_menu_group do if socket.assigns.titlebar_menu_group do
@@ -114,7 +118,9 @@ defmodule BDS.Desktop.ShellLive.TitlebarMenu do
defp rotate_group(socket, offset) do defp rotate_group(socket, offset) do
groups = socket.assigns.menu_groups || [] groups = socket.assigns.menu_groups || []
current_group = socket.assigns.titlebar_menu_group 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 if is_nil(current_index) or groups == [] do
socket socket

View File

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

View File

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

View File

@@ -40,7 +40,13 @@ defmodule BDS.Generation.Data do
post_snapshot_candidates post_snapshot_candidates
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.reduce(%{}, fn {post, index}, acc -> |> 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 case published_post_snapshot(project_data_dir, post) do
nil -> acc nil -> acc
@@ -54,7 +60,9 @@ defmodule BDS.Generation.Data do
|> then(fn published -> |> then(fn published ->
draft_candidates draft_candidates
|> merge_generation_snapshots(snapshots_by_id) |> 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() |> Map.values()
end) end)
|> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)}) |> 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 end
@spec resolve_posts_for_language([map()], String.t() | nil, map(), String.t() | nil) :: [map()] @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 || "")) target = String.downcase(to_string(target_language || ""))
main = String.downcase(to_string(main_language || "")) main = String.downcase(to_string(main_language || ""))
@@ -126,22 +139,42 @@ defmodule BDS.Generation.Data do
@spec build_generation_post_index([map()]) :: map() @spec build_generation_post_index([map()]) :: map()
def build_generation_post_index(posts) do 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 -> Enum.reduce(
{year, month_value, day_value} = local_date_parts!(post.created_at) posts,
month = String.pad_leading(Integer.to_string(month_value), 2, "0") %{
day = String.pad_leading(Integer.to_string(day_value), 2, "0") posts_by_category: %{},
year_month = "#{year}/#{month}" posts_by_tag: %{},
year_month_day = "#{year}/#{month}/#{day}" 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 acc
|> append_generation_index(:posts_by_year, year, post) |> 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, year_month, post)
|> append_generation_index(:posts_by_year_month_day, year_month_day, post) |> append_generation_index(:posts_by_year_month_day, year_month_day, post)
|> then(fn indexed -> |> then(fn indexed ->
indexed = Enum.reduce(post.categories || [], indexed, &append_generation_index(&2, :posts_by_category, &1, post)) indexed =
Enum.reduce(post.tags || [], indexed, &append_generation_index(&2, :posts_by_tag, &1, post)) Enum.reduce(
end) post.categories || [],
end) 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 end
## --- internals ----------------------------------------------------------- ## --- internals -----------------------------------------------------------
@@ -168,9 +201,11 @@ defmodule BDS.Generation.Data do
"page" => %{render_in_lists: false, show_title: true} "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, %{ 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) show_title: category_setting_flag(settings, :show_title, "show_title", true)
}) })
end) end)
@@ -207,23 +242,30 @@ defmodule BDS.Generation.Data do
{:ok, contents} -> {:ok, contents} ->
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents) {:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
%Post{fallback_post | %Post{
id: DocumentFields.get(fields, "id", fallback_post.id), fallback_post
title: DocumentFields.get(fields, "title", fallback_post.title) || "", | id: DocumentFields.get(fields, "id", fallback_post.id),
slug: DocumentFields.fetch!(fields, "slug"), title: DocumentFields.get(fields, "title", fallback_post.title) || "",
excerpt: Map.get(fields, "excerpt"), slug: DocumentFields.fetch!(fields, "slug"),
content: nil, excerpt: Map.get(fields, "excerpt"),
status: :published, content: nil,
author: Map.get(fields, "author"), status: :published,
language: Map.get(fields, "language", fallback_post.language), author: Map.get(fields, "author"),
do_not_translate: DocumentFields.get(fields, "doNotTranslate", fallback_post.do_not_translate || false), language: Map.get(fields, "language", fallback_post.language),
template_slug: DocumentFields.get(fields, "templateSlug", fallback_post.template_slug), do_not_translate:
created_at: DocumentFields.get(fields, "createdAt", fallback_post.created_at), DocumentFields.get(
updated_at: DocumentFields.get(fields, "updatedAt", fallback_post.updated_at), fields,
published_at: DocumentFields.get(fields, "publishedAt", fallback_post.published_at), "doNotTranslate",
file_path: fallback_post.file_path, fallback_post.do_not_translate || false
tags: Map.get(fields, "tags", fallback_post.tags || []), ),
categories: Map.get(fields, "categories", fallback_post.categories || []) 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} -> {:error, _reason} ->
@@ -231,13 +273,20 @@ defmodule BDS.Generation.Data do
end end
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) source_post_ids = Enum.map(published_posts, & &1.id)
translation_candidates = translation_candidates =
Repo.all( Repo.all(
from translation in Translation, 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], where: translation.status in [:published, :draft],
order_by: [asc: translation.translation_for, asc: translation.language] order_by: [asc: translation.translation_for, asc: translation.language]
) )
@@ -246,7 +295,13 @@ defmodule BDS.Generation.Data do
translation_candidates translation_candidates
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.reduce(%{}, fn {translation, index}, acc -> |> 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 case published_translation_snapshot(project_data_dir, translation) do
nil -> acc nil -> acc
@@ -288,18 +343,20 @@ defmodule BDS.Generation.Data do
{:ok, contents} -> {:ok, contents} ->
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents) {:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
%Translation{fallback_translation | %Translation{
id: DocumentFields.get(fields, "id", fallback_translation.id), fallback_translation
translation_for: DocumentFields.fetch!(fields, "translationFor"), | id: DocumentFields.get(fields, "id", fallback_translation.id),
language: DocumentFields.fetch!(fields, "language"), translation_for: DocumentFields.fetch!(fields, "translationFor"),
title: DocumentFields.get(fields, "title", fallback_translation.title) || "", language: DocumentFields.fetch!(fields, "language"),
excerpt: Map.get(fields, "excerpt", fallback_translation.excerpt), title: DocumentFields.get(fields, "title", fallback_translation.title) || "",
content: nil, excerpt: Map.get(fields, "excerpt", fallback_translation.excerpt),
status: :published, content: nil,
created_at: DocumentFields.get(fields, "createdAt", fallback_translation.created_at), status: :published,
updated_at: DocumentFields.get(fields, "updatedAt", fallback_translation.updated_at), created_at: DocumentFields.get(fields, "createdAt", fallback_translation.created_at),
published_at: DocumentFields.get(fields, "publishedAt", fallback_translation.published_at), updated_at: DocumentFields.get(fields, "updatedAt", fallback_translation.updated_at),
file_path: fallback_translation.file_path published_at:
DocumentFields.get(fields, "publishedAt", fallback_translation.published_at),
file_path: fallback_translation.file_path
} }
{:error, _reason} -> {:error, _reason} ->

View File

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

View File

@@ -19,10 +19,13 @@ defmodule BDS.Generation.Pagefind do
|> Enum.flat_map(fn language -> |> Enum.flat_map(fn language ->
route_language = route_language(plan.language, language) route_language = route_language(plan.language, language)
pages = pages_for_language(html_outputs, route_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.js"]), ui_js(language)},
{Path.join(prefix ++ ["pagefind-ui.css"]), ui_css()} {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(nil, 1), do: "index.html"
def root_output_path("", 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(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("", 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() @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, nil), do: Path.join([slug, "index.html"])
def page_output_path(slug, ""), do: page_output_path(slug, nil) def page_output_path(slug, ""), do: page_output_path(slug, nil)
def page_output_path(slug, language), do: Path.join([language, slug, "index.html"]) def page_output_path(slug, language), do: 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() 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, current_page: page_number,
total_pages: total_pages, total_pages: total_pages,
@@ -75,8 +94,12 @@ defmodule BDS.Generation.Paths do
@spec archive_or_root_href(language(), [String.t()], integer()) :: String.t() @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, _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() @spec root_page_href(language(), integer()) :: String.t()
def root_page_href(route_language, page_number) when page_number <= 1 do 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() @spec archive_route_segment(any()) :: String.t()
def archive_route_segment(nil), do: "" 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 @spec normalize_base_url(String.t() | nil) :: String.t() | nil
def normalize_base_url(nil), do: nil def normalize_base_url(nil), do: nil

View File

@@ -44,7 +44,8 @@ defmodule BDS.Generation.Renderers do
end end
@doc "Render an archive page (category, tag, year) with pagination." @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 def render_archive_page(plan, title, posts, language, kind, pagination) do
fallback = fn -> fallback = fn ->
items = items =
@@ -130,7 +131,15 @@ defmodule BDS.Generation.Renderers do
end end
@doc "Render a list/archive page through the project template, falling back to inline." @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() String.t()
def render_list_output( def render_list_output(
%{project_id: project_id, language: main_language}, %{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) 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 -> Enum.map(
page_path = "/page/#{page_number}" Paths.root_pagination_pages(length(published_list_posts), plan.max_posts_per_page),
fn page_number ->
page_path = "/page/#{page_number}"
url_entry( url_entry(
Paths.url_for_path(plan.base_url, page_path), Paths.url_for_path(plan.base_url, page_path),
latest_post_updated_at, latest_post_updated_at,
"daily", "daily",
"0.9", "0.9",
build_hreflang_links(plan.base_url, page_path, plan.language, all_languages) build_hreflang_links(plan.base_url, page_path, plan.language, all_languages)
) )
end) ++ end
) ++
Enum.map(translatable_posts, fn post -> Enum.map(translatable_posts, fn post ->
post_path = Paths.relative_path_to_url_path(Paths.post_output_path(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) build_hreflang_links(plan.base_url, year_path, plan.language, all_languages)
) )
end) ++ end) ++
Enum.map(Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc), fn {year_month, _posts} -> Enum.map(
month_path = "/#{year_month}" Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc),
fn {year_month, _posts} ->
month_path = "/#{year_month}"
url_entry( url_entry(
Paths.url_for_path(plan.base_url, month_path), Paths.url_for_path(plan.base_url, month_path),
latest_post_updated_at, latest_post_updated_at,
"monthly", "monthly",
"0.5", "0.5",
build_hreflang_links(plan.base_url, month_path, plan.language, all_languages) build_hreflang_links(plan.base_url, month_path, plan.language, all_languages)
) )
end) ++ 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}" 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( url_entry(
Paths.url_for_path(plan.base_url, day_path), Paths.url_for_path(plan.base_url, day_path),
latest_post_updated_at, latest_post_updated_at,
"monthly", "monthly",
"0.4", "0.4",
build_hreflang_links(plan.base_url, day_path, plan.language, all_languages) build_hreflang_links(plan.base_url, day_path, plan.language, all_languages)
) )
end) ++ end
) ++
Enum.map(Enum.sort_by(post_index.posts_by_category, &elem(&1, 0)), fn {category, _posts} -> Enum.map(Enum.sort_by(post_index.posts_by_category, &elem(&1, 0)), fn {category, _posts} ->
category_path = "/category/#{Paths.archive_route_segment(category)}" 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, relative_path_to_url_path: 1,
url_path_to_relative_index_path: 1 url_path_to_relative_index_path: 1
] ]
import BDS.Generation.Progress, only: [report_validation_compare_progress: 3] import BDS.Generation.Progress, only: [report_validation_compare_progress: 3]
import BDS.Generation.Sitemap, only: [extract_locs: 1, loc_to_project_path: 2] import BDS.Generation.Sitemap, only: [extract_locs: 1, loc_to_project_path: 2]
@@ -20,7 +21,11 @@ defmodule BDS.Generation.Validation do
end end
@spec build_post_timestamp_checks(String.t(), [map()], map()) :: [map()] @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 -> Enum.map(published_route_posts, fn post ->
relative_path = BDS.Generation.Paths.post_output_path(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.map(&loc_to_project_path(&1, params.base_url))
|> Enum.reduce(MapSet.new(), &MapSet.put(&2, normalize_url_path(&1))) |> Enum.reduce(MapSet.new(), &MapSet.put(&2, normalize_url_path(&1)))
|> then(fn expected_paths -> |> 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)) MapSet.put(acc, normalize_url_path(path))
end) end)
end) end)
{existing_html_path_set, zero_byte_html_path_set} = {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 = missing_url_paths =
expected_path_set expected_path_set
@@ -119,11 +130,14 @@ defmodule BDS.Generation.Validation do
acc acc
true -> 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}} -> {{: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 if mtime_ms(post_stat) > effective_generated_at_ms do
MapSet.put(acc, normalized_url_path) MapSet.put(acc, normalized_url_path)
@@ -233,7 +247,18 @@ defmodule BDS.Generation.Validation do
nil -> nil ->
case Regex.run(~r|^/(\d{4})/(\d{2})/(\d{2})/([^/]+)$|, path) do case Regex.run(~r|^/(\d{4})/(\d{2})/(\d{2})/([^/]+)$|, path) do
[_, year, month, day, slug] -> [_, 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 -> nil ->
case Regex.run(~r|^/(\d{4})/(\d{2})(?:/page/\d+)?$|, path) do case Regex.run(~r|^/(\d{4})/(\d{2})(?:/page/\d+)?$|, path) do
@@ -281,29 +306,43 @@ defmodule BDS.Generation.Validation do
end) end)
enriched = enriched =
Enum.reduce(initial_plan.requested_post_routes, %{initial_plan | requested_post_routes: targeted_post_routes}, fn route, acc -> Enum.reduce(
case Enum.find(published_posts, &post_matches_route?(&1, route)) do initial_plan.requested_post_routes,
nil -> %{initial_plan | requested_post_routes: targeted_post_routes},
acc fn route, acc ->
|> update_in([:requested_years], &MapSet.put(&1, route.year)) case Enum.find(published_posts, &post_matches_route?(&1, route)) do
|> update_in([:requested_year_months], &MapSet.put(&1, route_month_key(route.year, route.month))) nil ->
|> Map.put(:request_root_routes, true) 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 -> post ->
{year, month, _day} = local_date_parts!(post.created_at) {year, month, _day} = local_date_parts!(post.created_at)
acc acc
|> update_in([:requested_category_slugs], fn set -> |> update_in([:requested_category_slugs], fn set ->
Enum.reduce(post.categories || [], set, &MapSet.put(&2, archive_route_segment(&1))) Enum.reduce(
end) post.categories || [],
|> update_in([:requested_tag_slugs], fn set -> set,
Enum.reduce(post.tags || [], set, &MapSet.put(&2, archive_route_segment(&1))) &MapSet.put(&2, archive_route_segment(&1))
end) )
|> update_in([:requested_years], &MapSet.put(&1, year)) end)
|> update_in([:requested_year_months], &MapSet.put(&1, route_month_key(year, month))) |> update_in([:requested_tag_slugs], fn set ->
|> Map.put(:request_root_routes, true) 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
end) )
language_plans = language_plans =
initial_plan.language_plans initial_plan.language_plans
@@ -314,8 +353,10 @@ defmodule BDS.Generation.Validation do
%{ %{
enriched enriched
| requested_category_slugs: MapSet.intersection(enriched.requested_category_slugs, available_category_slugs), | requested_category_slugs:
requested_tag_slugs: MapSet.intersection(enriched.requested_tag_slugs, available_tag_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 language_plans: language_plans
} }
end end
@@ -351,13 +392,15 @@ defmodule BDS.Generation.Validation do
{nil, path} {nil, path}
end end
_other -> {nil, path} _other ->
{nil, path}
end end
end end
@spec targeted_output?(String.t(), map(), String.t() | nil, [String.t()]) :: boolean() @spec targeted_output?(String.t(), map(), String.t() | nil, [String.t()]) :: boolean()
def targeted_output?(relative_path, targeted_plan, main_language, additional_languages) do 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 = plan =
case language do case language do
@@ -384,7 +427,11 @@ defmodule BDS.Generation.Validation do
end end
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 defp targeted_output_for_plan?(relative_path, plan, _main?) do
cond do cond do
@@ -400,8 +447,18 @@ defmodule BDS.Generation.Validation do
MapSet.member?(plan.requested_tag_slugs, slug) MapSet.member?(plan.requested_tag_slugs, slug)
Regex.match?(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path) -> 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) [_, year, month, day, slug] =
MapSet.member?(plan.requested_post_routes, route_key(String.to_integer(year), String.to_integer(month), String.to_integer(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) -> Regex.match?(~r|^(\d{4})/(\d{2})/index\.html$|, relative_path) ->
[_, year, month] = Regex.run(~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) has_lfs: has_lfs_configured?(project_dir)
}} }}
else else
{:error, :not_found} = error -> error {:error, :not_found} = error ->
error
{:error, _reason} -> {:error, _reason} ->
{:ok, {:ok,
%{ %{
@@ -74,7 +76,8 @@ defmodule BDS.Git do
def status(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do def status(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id), 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)}} {:ok, %{files: parse_status(output)}}
end end
end end
@@ -112,7 +115,8 @@ defmodule BDS.Git do
when is_binary(project_id) and is_binary(branch) and is_list(opts) do when is_binary(project_id) and is_binary(branch) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id), with {:ok, project_dir} <- project_dir(project_id),
{:ok, local_log} <- run_git(project_dir, ["log", "--format=%H%x09%s", branch], opts), {: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) local_commits = parse_local_history(local_log)
remote_hashes = MapSet.new(parse_remote_history(remote_log)) remote_hashes = MapSet.new(parse_remote_history(remote_log))
local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash)) local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash))
@@ -121,7 +125,9 @@ defmodule BDS.Git do
remote_hashes remote_hashes
|> MapSet.difference(local_hashes) |> MapSet.difference(local_hashes)
|> MapSet.to_list() |> 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 = commits =
Enum.map(local_commits, fn commit -> Enum.map(local_commits, fn commit ->
@@ -136,7 +142,8 @@ defmodule BDS.Git do
def file_history(project_id, file_path, opts \\ []) def file_history(project_id, file_path, opts \\ [])
when is_binary(project_id) and is_binary(file_path) and is_list(opts) do when is_binary(project_id) and is_binary(file_path) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id), 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)}} {:ok, %{commits: parse_local_history(output) |> Enum.take(50)}}
else else
{:error, {:git_failed, _message}} -> {:ok, %{commits: []}} {: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 def fetch(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id) do with {:ok, project_dir} <- project_dir(project_id) do
case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do
{:ok, output} -> {:ok, %{updated: true, output: output}} {:ok, output} ->
{:error, {:git_failed, message}} -> structured_git_error(project_dir, :fetch, message, opts) {:ok, %{updated: true, output: output}}
{:error, {:git_failed, message}} ->
structured_git_error(project_dir, :fetch, message, opts)
end end
end end
end end
@@ -177,9 +187,11 @@ defmodule BDS.Git do
end end
def reconcile(project_id, old_commit, new_commit, opts \\ []) 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), 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)}} {:ok, %{changed: parse_changed_files(output)}}
end end
end end
@@ -197,7 +209,14 @@ defmodule BDS.Git do
{:ok, local_branch} <- current_branch(project_dir, opts) do {:ok, local_branch} <- current_branch(project_dir, opts) do
case upstream_branch(project_dir, opts) do case upstream_branch(project_dir, opts) do
{:ok, nil} -> {: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, upstream_branch} ->
{:ok, {:ok,
@@ -316,7 +335,11 @@ defmodule BDS.Git do
end end
defp upstream_branch(project_dir, opts) do 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)} {:ok, output} -> {:ok, blank_to_nil(output)}
{:error, {:git_failed, _message}} -> {:ok, nil} {:error, {:git_failed, _message}} -> {:ok, nil}
end end
@@ -364,21 +387,37 @@ defmodule BDS.Git do
defp parse_changed_files(output) do defp parse_changed_files(output) do
base = fn -> %{added: [], modified: [], deleted: [], renamed: []} end base = fn -> %{added: [], modified: [], deleted: [], renamed: []} end
Enum.reduce(String.split(output, "\n", trim: true), %{posts: base.(), scripts: base.(), templates: base.()}, fn line, acc -> Enum.reduce(
case String.split(line, "\t", trim: true) do String.split(output, "\n", trim: true),
["A", path] -> update_changed(acc, path, :added, path) %{posts: base.(), scripts: base.(), templates: base.()},
["M", path] -> update_changed(acc, path, :modified, path) fn line, acc ->
["D", path] -> update_changed(acc, path, :deleted, path) case String.split(line, "\t", trim: true) do
["R" <> _score, old_path, new_path] -> update_changed(acc, new_path, :renamed, %{old: old_path, new: new_path}) ["A", path] ->
_other -> acc 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) )
end end
defp update_changed(acc, path, key, value) do defp update_changed(acc, path, key, value) do
case category_for_path(path) do case category_for_path(path) do
nil -> acc nil ->
category -> Map.update!(acc, category, &Map.update!(&1, key, fn items -> items ++ [value] end)) acc
category ->
Map.update!(acc, category, &Map.update!(&1, key, fn items -> items ++ [value] end))
end end
end end
@@ -427,6 +466,7 @@ defmodule BDS.Git do
defp auth_guidance(provider, platform) do defp auth_guidance(provider, platform) do
provider_label = provider || :git provider_label = provider || :git
"Authentication failed for #{provider_label} on #{platform}. Configure SSH keys or a credential helper that works non-interactively." "Authentication failed for #{provider_label} on #{platform}. Configure SSH keys or a credential helper that works non-interactively."
end end

View File

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

View File

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

View File

@@ -19,7 +19,16 @@ defmodule BDS.ImportDefinitions.ImportDefinition do
def changeset(definition, attrs) do def changeset(definition, attrs) do
definition 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]) |> validate_required([:id, :project_id, :name, :created_at, :updated_at])
end end
end end

View File

@@ -8,7 +8,8 @@ defmodule BDS.ImportExecution do
alias BDS.Repo alias BDS.Repo
alias BDS.Tags 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) normalized_report = normalize_report(report)
default_author = Keyword.get(opts, :default_author) || project_default_author(project_id) default_author = Keyword.get(opts, :default_author) || project_default_author(project_id)
uploads_folder_path = Keyword.get(opts, :uploads_folder_path) uploads_folder_path = Keyword.get(opts, :uploads_folder_path)
@@ -42,16 +43,52 @@ defmodule BDS.ImportExecution do
started_at = System.monotonic_time(:millisecond) started_at = System.monotonic_time(:millisecond)
notify_progress(on_progress, "tags", 0, taxonomy_total, "creating_tags", started_at) 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) 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) 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) 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) notify_progress(on_progress, "complete", 1, 1, "import_complete", started_at)
{:ok, result} {:ok, result}
@@ -68,41 +105,99 @@ defmodule BDS.ImportExecution do
|> Enum.reduce(result, fn {item, index}, acc -> |> Enum.reduce(result, fn {item, index}, acc ->
cond do cond do
Map.get(item, :exists_in_project) || not is_nil(Map.get(item, :mapped_to)) -> 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) put_in(acc, [:tags, :skipped], acc.tags.skipped + 1)
true -> true ->
case Tags.create_tag(%{project_id: project_id, name: item.name}) do case Tags.create_tag(%{project_id: project_id, name: item.name}) do
{:ok, _tag} -> {: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) put_in(acc, [:tags, :created], acc.tags.created + 1)
{:error, _reason} -> {: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) put_in(acc, [:tags, :skipped], acc.tags.skipped + 1)
end end
end end
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) total = length(items)
phase = Atom.to_string(bucket) phase = Atom.to_string(bucket)
Enum.with_index(items, 1) Enum.with_index(items, 1)
|> Enum.reduce(result, fn {item, index}, acc -> |> Enum.reduce(result, fn {item, index}, acc ->
notify_progress(on_progress, phase, index, total, "processing:#{item.title}", started_at) 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)
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) total = length(items)
items items
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.reduce(result, fn {item, index}, acc -> |> 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 cond do
item.status == "missing" -> item.status == "missing" ->
@@ -116,7 +211,9 @@ defmodule BDS.ImportExecution do
true -> true ->
case import_media_item(project_id, item, default_author, uploads_folder_path, acc) do 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} -> {:error, reason} ->
acc acc
|> put_in([:media, :errors], acc.media.errors + 1) |> put_in([:media, :errors], acc.media.errors + 1)
@@ -127,7 +224,15 @@ defmodule BDS.ImportExecution do
end) end)
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 cond do
item.status in ["update", "content-duplicate", "duplicate"] -> item.status in ["update", "content-duplicate", "duplicate"] ->
put_in(result, [bucket, :skipped], get_in(result, [bucket, :skipped]) + 1) 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 defp overwrite_post_item(item, default_author, tag_mapping, category_mapping) do
case Repo.get(Post, item.existing_id) do case Repo.get(Post, item.existing_id) do
nil -> {:error, :not_found} nil ->
{:error, :not_found}
%Post{} = post -> %Post{} = post ->
Posts.update_post(post.id, %{ 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 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) 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) linked_post_ids = parent_post_ids(item, result)
if source_path && File.exists?(source_path) do if source_path && File.exists?(source_path) do
@@ -221,7 +333,10 @@ defmodule BDS.ImportExecution do
checksum: checksum 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 case Media.import_media(attrs) do
{:ok, %{id: media_id} = media} -> {:ok, %{id: media_id} = media} ->
@@ -255,8 +370,12 @@ defmodule BDS.ImportExecution do
defp parent_post_ids(item, result) do defp parent_post_ids(item, result) do
case Map.get(item, :parent_wp_id) do case Map.get(item, :parent_wp_id) do
nil -> [] nil ->
0 -> [] []
0 ->
[]
wp_id -> wp_id ->
case Map.get(result.wp_id_to_post_id, wp_id) do case Map.get(result.wp_id_to_post_id, wp_id) do
nil -> [] nil -> []
@@ -265,7 +384,8 @@ defmodule BDS.ImportExecution do
end end
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)) update_in(result, [:wp_id_to_post_id], &Map.put(&1, wp_id, post_id))
end end
@@ -333,7 +453,9 @@ defmodule BDS.ImportExecution do
end end
defp maybe_apply_page_category(item, :pages) do 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} %{item | categories: categories}
end end
@@ -349,7 +471,11 @@ defmodule BDS.ImportExecution do
true -> key true -> key
end 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)
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)
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) Path.join(uploads_folder_path, relative_path)
end end
defp uploads_source_path(_relative_path, _uploads_folder_path), do: nil 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) eta = compute_eta(current, total, started_at)
try do try do
@@ -466,7 +594,9 @@ defmodule BDS.ImportExecution do
:ok :ok
end 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 elapsed = System.monotonic_time(:millisecond) - started_at
if current >= total, do: 0, else: trunc(elapsed / current * (total - current)) if current >= total, do: 0, else: trunc(elapsed / current * (total - current))
end end

View File

@@ -114,9 +114,11 @@ defmodule BDS.Maintenance do
phases = [ phases = [
{"Comparing project metadata", fn -> project_metadata_diff_reports(project_id) end}, {"Comparing project metadata", fn -> project_metadata_diff_reports(project_id) end},
{"Comparing post metadata", fn -> post_diff_reports(project_id, project) 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 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 script metadata", fn -> script_diff_reports(project_id, project) end},
{"Comparing template metadata", fn -> template_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} {"Comparing embeddings", fn -> Embeddings.diff_reports(project_id) end}
@@ -132,7 +134,9 @@ defmodule BDS.Maintenance do
fun.() fun.()
end) 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) orphan_reports = orphan_reports(project_id, project)
:ok = report_metadata_diff_complete(on_progress) :ok = report_metadata_diff_complete(on_progress)

View File

@@ -87,7 +87,10 @@ defmodule BDS.Maintenance.DiffComputation do
end 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_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) when is_atom(value), do: Atom.to_string(value)
def normalize_nested_diff_value(value), do: value def normalize_nested_diff_value(value), do: value
end end

View File

@@ -110,10 +110,18 @@ defmodule BDS.Maintenance.DiffReports do
diff_field("author", post.author, Map.get(fields, "author")), diff_field("author", post.author, Map.get(fields, "author")),
diff_field("language", post.language, Map.get(fields, "language")), diff_field("language", post.language, Map.get(fields, "language")),
diff_field("status", post.status, DocumentFields.get(fields, "status")), 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("created_at", post.created_at, DocumentFields.get(fields, "createdAt")),
diff_field("updated_at", post.updated_at, DocumentFields.get(fields, "updatedAt")), 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("tags", post.tags, Map.get(fields, "tags", [])),
diff_field("categories", post.categories, Map.get(fields, "categories", [])) 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("title", script.title, Map.get(fields, "title")),
diff_field("entrypoint", script.entrypoint, Map.get(fields, "entrypoint")), diff_field("entrypoint", script.entrypoint, Map.get(fields, "entrypoint")),
diff_field("enabled", script.enabled, Map.get(fields, "enabled")), 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")) diff_field("updated_at", script.updated_at, DocumentFields.get(fields, "updatedAt"))
] ]
|> Enum.reject(&is_nil/1) |> 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("title", template.title, Map.get(fields, "title")),
diff_field("enabled", template.enabled, Map.get(fields, "enabled")), diff_field("enabled", template.enabled, Map.get(fields, "enabled")),
diff_field("created_at", template.created_at, DocumentFields.get(fields, "createdAt")), diff_field(
diff_field("updated_at", template.updated_at, DocumentFields.get(fields, "updatedAt")) "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) |> Enum.reject(&is_nil/1)

View File

@@ -30,7 +30,9 @@ defmodule BDS.MCP.AgentConfig do
end end
def config_path(:claude_code, home_dir), do: Path.join(home_dir, ".claude.json") 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 def packaged_executable_path(install_root, platform) when is_binary(install_root) do
executable_name = executable_name =
@@ -90,12 +92,21 @@ defmodule BDS.MCP.AgentConfig do
defp merge_config(:github_copilot, config, command, args) do defp merge_config(:github_copilot, config, command, args) do
servers = Map.get(config, "servers", %{}) 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 end
defp merge_config(:claude_code, config, command, args) do defp merge_config(:claude_code, config, command, args) do
servers = Map.get(config, "mcpServers", %{}) 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 end
defp remove_server_entry(:github_copilot, config) do defp remove_server_entry(:github_copilot, config) do

View File

@@ -8,7 +8,11 @@ defmodule BDS.MCP.Proposal do
schema "mcp_proposals" do schema "mcp_proposals" do
field :kind, :string 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 :entity_id, :string
field :data, :map field :data, :map
field :created_at, :integer field :created_at, :integer
@@ -17,7 +21,9 @@ defmodule BDS.MCP.Proposal do
def changeset(proposal, attrs) do def changeset(proposal, attrs) do
proposal 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]) |> validate_required([:id, :kind, :status, :entity_id, :data, :created_at, :expires_at])
|> unique_constraint(:status, name: :mcp_proposals_entity_idx) |> unique_constraint(:status, name: :mcp_proposals_entity_idx)
end end

View File

@@ -74,12 +74,15 @@ defmodule BDS.MCP.ProposalStore do
defp mark_status(id, status) do defp mark_status(id, status) do
case Repo.get(Proposal, id) do case Repo.get(Proposal, id) do
nil -> nil nil ->
nil
proposal -> proposal ->
Repo.delete_all( Repo.delete_all(
from other in Proposal, from other in Proposal,
where: 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 other.status == ^status
) )
@@ -90,6 +93,7 @@ defmodule BDS.MCP.ProposalStore do
end end
defp derive_entity_id(data) do 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
end end

View File

@@ -138,8 +138,11 @@ defmodule BDS.MCP.Server do
case URI.parse(target) do case URI.parse(target) do
%URI{path: "/mcp"} -> %URI{path: "/mcp"} ->
case GenServer.call(__MODULE__, {:http_request, request}, 5_000) do case GenServer.call(__MODULE__, {:http_request, request}, 5_000) do
{:ok, status, body} -> http_response(status, Jason.encode!(body), "application/json", request.headers) {:ok, status, body} ->
{:error, status, body} -> http_response(status, body, "text/plain", request.headers) http_response(status, Jason.encode!(body), "application/json", request.headers)
{:error, status, body} ->
http_response(status, body, "text/plain", request.headers)
end end
_other -> _other ->
@@ -170,7 +173,10 @@ defmodule BDS.MCP.Server do
success_response(id, %{ success_response(id, %{
"protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"), "protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"),
"capabilities" => %{"tools" => %{}, "resources" => %{}}, "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" -> "tools/list" ->
@@ -196,10 +202,17 @@ defmodule BDS.MCP.Server do
arguments = Map.get(params, "arguments", %{}) arguments = Map.get(params, "arguments", %{})
case BDS.MCP.call_tool(name, arguments) do case BDS.MCP.call_tool(name, arguments) do
{:ok, result} -> {:ok, success_response(id, %{"content" => [%{"type" => "json", "json" => result}]})} {:ok, result} ->
{:error, :unknown_tool} -> {:error, error_response(id, -32601, "Unknown tool")} {:ok, success_response(id, %{"content" => [%{"type" => "json", "json" => result}]})}
{:error, :not_found} -> {:error, error_response(id, -32004, "Not found")}
{:error, reason} -> {:error, error_response(id, -32000, inspect(reason))} {: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
end end
@@ -286,7 +299,8 @@ defmodule BDS.MCP.Server do
|> IO.iodata_to_binary() |> IO.iodata_to_binary()
end 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(400), do: "Bad Request"
defp reason_body(404), do: "Not Found" defp reason_body(404), do: "Not Found"

View File

@@ -9,8 +9,15 @@ defmodule BDS.MCP.Stdio do
if line != "" do if line != "" do
response = response =
case Jason.decode(line) do case Jason.decode(line) do
{:ok, payload} -> handle_payload(payload) {:ok, payload} ->
{:error, _reason} -> %{"jsonrpc" => "2.0", "id" => nil, "error" => %{"code" => -32700, "message" => "Parse error"}} handle_payload(payload)
{:error, _reason} ->
%{
"jsonrpc" => "2.0",
"id" => nil,
"error" => %{"code" => -32700, "message" => "Parse error"}
}
end end
IO.write(Jason.encode!(response) <> "\n") IO.write(Jason.encode!(response) <> "\n")
@@ -18,14 +25,22 @@ defmodule BDS.MCP.Stdio do
end) end)
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", "jsonrpc" => "2.0",
"id" => id, "id" => id,
"result" => %{ "result" => %{
"protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"), "protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"),
"capabilities" => %{"tools" => %{}, "resources" => %{}}, "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 end
@@ -34,10 +49,26 @@ defmodule BDS.MCP.Stdio do
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"tools" => BDS.MCP.list_tools()}} %{"jsonrpc" => "2.0", "id" => id, "result" => %{"tools" => BDS.MCP.list_tools()}}
end 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 case BDS.MCP.call_tool(name, Map.get(params, "arguments", %{})) do
{:ok, result} -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"content" => [%{"type" => "json", "json" => result}]}} {:ok, result} ->
{:error, reason} -> %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32000, "message" => inspect(reason)}} %{
"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
end end
@@ -45,17 +76,38 @@ defmodule BDS.MCP.Stdio do
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"resources" => BDS.MCP.list_resources()}} %{"jsonrpc" => "2.0", "id" => id, "result" => %{"resources" => BDS.MCP.list_resources()}}
end 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 case BDS.MCP.read_resource(uri) do
{:ok, result} -> {: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} -> {: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
end end
defp handle_payload(%{"jsonrpc" => "2.0", "id" => id}) do 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
end end

View File

@@ -60,8 +60,11 @@ defmodule BDS.MCP.Tools do
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}} @spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
def validate_template(source) when is_binary(source) do def validate_template(source) when is_binary(source) do
case Liquex.parse(source) do case Liquex.parse(source) do
{:ok, _ast} -> {:ok, %{valid: true, errors: []}} {:ok, _ast} ->
{:error, reason, line} -> {:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}} {:ok, %{valid: true, errors: []}}
{:error, reason, line} ->
{:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}}
end end
end end
@@ -276,7 +279,8 @@ defmodule BDS.MCP.Tools do
ttl_ms: @proposal_ttl_app_ms 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
end end

View File

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

View File

@@ -141,7 +141,12 @@ defmodule BDS.Media.Sidecars do
media media
end 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 Translation.t() | :skip | :ok
def upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar, opts) do 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 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 = missing_paths =
media media
|> thumbnail_paths() |> 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) |> Enum.reject(&File.exists?/1)
next_acc = next_acc =

View File

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

View File

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

View File

@@ -122,7 +122,8 @@ defmodule BDS.Posts.AutoTranslation do
defp media_needed?(media_id, language) do defp media_needed?(media_id, language) do
case Repo.get(Media.Media, media_id) 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?( not Repo.exists?(
from translation in Media.Translation, from translation in Media.Translation,
where: translation.translation_for == ^media_id and translation.language == ^language 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 schema "post_links" do
belongs_to :source_post, BDS.Posts.Post, foreign_key: :source_post_id, references: :id, type: :string belongs_to :source_post, BDS.Posts.Post,
belongs_to :target_post, BDS.Posts.Post, foreign_key: :target_post_id, references: :id, type: :string 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 :link_text, :string
field :created_at, :integer field :created_at, :integer

View File

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

View File

@@ -64,7 +64,11 @@ defmodule BDS.Preview do
{:reply, reply, next_state} {:reply, reply, next_state}
end 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} {:reply, {:ok, public_server(state.current)}, state}
end end
@@ -224,7 +228,9 @@ defmodule BDS.Preview do
end end
defp draft_preview_translation(_post_id, nil, _post_language), do: nil 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 defp draft_preview_translation(post_id, requested_language, _post_language) do
Repo.get_by(Translation, translation_for: post_id, language: requested_language) 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 || "")} {uri.path || "/", URI.decode_query(uri.query || "")}
end 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 when is_binary(content_type) and is_binary(body) do
if String.starts_with?(content_type, "text/html") do if String.starts_with?(content_type, "text/html") do
%{response | body: apply_preview_overrides(body, query_params)} %{response | body: apply_preview_overrides(body, query_params)}
@@ -465,7 +474,8 @@ defmodule BDS.Preview do
end end
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"]) theme_override = normalize_pico_theme_override(query_params["theme"])
mode_override = normalize_mode_override(query_params["mode"]) mode_override = normalize_mode_override(query_params["mode"])
@@ -506,7 +516,9 @@ defmodule BDS.Preview do
[html_tag] -> [html_tag] ->
replacement = replacement =
if String.contains?(html_tag, attribute <> "=") do 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 else
String.replace_suffix(html_tag, ">", ~s( #{attribute}="#{value}">)) String.replace_suffix(html_tag, ">", ~s( #{attribute}="#{value}">))
end end
@@ -520,7 +532,11 @@ defmodule BDS.Preview do
defp not_found_assigns(query_params) 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 end
defp maybe_put_assign(assigns, _key, nil, _mapper), do: assigns defp maybe_put_assign(assigns, _key, nil, _mapper), do: assigns

View File

@@ -134,7 +134,8 @@ defmodule BDS.Projects do
sync_filesystem_metadata(project) sync_filesystem_metadata(project)
end end
{:error, reason} -> {:error, reason} {:error, reason} ->
{:error, reason}
end end
end end
@@ -166,7 +167,8 @@ defmodule BDS.Projects do
@spec delete_project(String.t()) :: @spec delete_project(String.t()) ::
{:ok, Project.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 def delete_project(project_id) when is_binary(project_id) do
case Repo.get(Project, project_id) do case Repo.get(Project, project_id) do
nil -> nil ->
@@ -180,7 +182,9 @@ defmodule BDS.Projects do
%Project{} = project -> %Project{} = project ->
internal_dir = if is_nil(project.data_path), do: project_data_dir(project), else: nil 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.transaction(fn ->
Repo.delete!(project) Repo.delete!(project)

View File

@@ -7,7 +7,11 @@ defmodule BDS.Rebuild do
timeout = Keyword.get(opts, :timeout, :infinity) timeout = Keyword.get(opts, :timeout, :infinity)
items 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 |> Enum.map(fn
{:ok, item} -> item {:ok, item} -> item
{:exit, reason} -> exit(reason) {:exit, reason} -> exit(reason)

View File

@@ -26,7 +26,8 @@ defmodule BDS.ReleasePackaging do
] ]
end 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) normalized_platform = normalize_platform(platform)
payload_name = "bds2-#{normalized_platform}-#{version}" payload_name = "bds2-#{normalized_platform}-#{version}"
payload_root = Path.join(output_dir, payload_name) 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(platform) when platform in [:macos, :linux, :windows], do: platform
defp normalize_platform(:darwin), do: :macos 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(:windows), do: ".zip"
defp archive_extension(_platform), do: ".tar.gz" defp archive_extension(_platform), do: ".tar.gz"
@@ -107,7 +110,9 @@ defmodule BDS.ReleasePackaging do
relative_entries = collect_entries(metadata.payload_root) relative_entries = collect_entries(metadata.payload_root)
cwd = metadata.output_dir |> String.to_charlist() cwd = metadata.output_dir |> String.to_charlist()
archive = metadata.archive_path |> 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 case :zip.create(archive, entries, cwd: cwd) do
{:ok, _archive_path} -> :ok {:ok, _archive_path} -> :ok
@@ -116,7 +121,13 @@ defmodule BDS.ReleasePackaging do
end end
defp create_archive(metadata) do 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, 0} -> :ok
{output, status} -> {:error, {:tar_failed, status, output}} {output, status} -> {:error, {:tar_failed, status, output}}
end end

View File

@@ -7,9 +7,14 @@ defmodule BDS.Rendering do
def render_post_page(project_id, template_slug, assigns) def render_post_page(project_id, template_slug, assigns)
when is_binary(project_id) and is_map(assigns) do 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} <- {: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} {:ok, rendered}
end end
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 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), with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :list, nil),
{:ok, rendered} <- {: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} {:ok, rendered}
end end
end end
def render_not_found_page(project_id, assigns \\ %{}) def render_not_found_page(project_id, assigns \\ %{})
when is_binary(project_id) and is_map(assigns) do 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} <- {: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} {:ok, rendered}
end end
end end

View File

@@ -54,7 +54,9 @@ defmodule BDS.Rendering.Metadata do
|> Enum.uniq() |> Enum.uniq()
|> Enum.map(fn language -> |> Enum.map(fn language ->
normalized = I18n.normalize_language(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, code: normalized,
@@ -84,9 +86,17 @@ defmodule BDS.Rendering.Metadata do
order_by: [asc: translation.language] 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 -> 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)
end end

View File

@@ -36,7 +36,9 @@ defmodule BDS.Scripting do
runtime().execute(source, entrypoint, args, opts) runtime().execute(source, entrypoint, args, opts)
end 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()} {:ok, term()} | {:error, term()}
def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ []) def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ [])
when is_binary(project_id) and is_binary(source) and is_binary(entrypoint) and 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)) execute(source, entrypoint, args, Keyword.put(opts, :capabilities, capabilities))
end 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 \\ []) 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 when is_binary(project_id) and is_binary(source) and is_list(args) and is_list(opts) do
config = Application.fetch_env!(:bds, :scripting) config = Application.fetch_env!(:bds, :scripting)
timeout = Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout)) 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, nil} -> {:ok, ""}
{:ok, value} -> {:ok, to_string(value)} {:ok, value} -> {:ok, to_string(value)}
{:error, _reason} -> {:ok, ""} {:error, _reason} -> {:ok, ""}

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