chore: section 12 closed, had to do with map and atoms

This commit is contained in:
2026-05-01 17:37:08 +02:00
parent eb358bf512
commit c12001307f
18 changed files with 1025 additions and 386 deletions

View File

@@ -121,11 +121,9 @@ _None._ All modules previously on the queue have been split; refresh the queue i
## 12. Atom/String Key Duality ## 12. Atom/String Key Duality
**Status:** open, low priority. **Status:** ✅ done (2026-05-01). Same-name atom/string boundary reads now use `BDS.MapUtils.attr/2` or `attr/3` instead of nested `Map.get/3` or `Map.get/2 || Map.get/2` fallbacks. The cleanup covers AI endpoint/chat/one-shot attrs, model capabilities, rendering assigns and list pagination/archive contexts, UI shortcut/filter params, metadata category settings, metadata-diff repair payloads, CLI sync payloads, chat tool calls, and duplicate/metadata-diff editor payloads.
**Pattern:** `Map.get(assigns, :language, Map.get(assigns, "language", default))` in many editors and capability bridges. **Rule:** atoms internally, strings only at JSON/HTTP/form/render boundaries. If a boundary must accept both atom and string keys for the same snake_case field, use `BDS.MapUtils.attr/2` or `attr/3`; do not open-code same-name dual-key reads.
**Plan:** normalize at boundaries. Adopt the rule "atoms internally, strings only at JSON/HTTP boundaries", and use `attr/2` (post-#6 consolidation) at every boundary point.
--- ---
@@ -161,6 +159,8 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search`
### 2026-05-01 ### 2026-05-01
- **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.
- **`Jason.decode!/1` on external HTTP responses**: replaced the 2 scoped OpenAI-compatible runtime response decodes with `Jason.decode/1` and tagged `{:error, %{kind: :invalid_json_response, reason: reason}}` propagation for malformed `/models` and `/chat/completions` bodies. Added regressions covering endpoint model listing through a fake HTTP client and generation through a local Bandit server. Section 9 is closed. - **`Jason.decode!/1` on external HTTP responses**: replaced the 2 scoped OpenAI-compatible runtime response decodes with `Jason.decode/1` and tagged `{:error, %{kind: :invalid_json_response, reason: reason}}` propagation for malformed `/models` and `/chat/completions` bodies. Added regressions covering endpoint model listing through a fake HTTP client and generation through a local Bandit server. Section 9 is closed.
### 2026-05-10 ### 2026-05-10

View File

@@ -6,6 +6,7 @@ defmodule BDS.AI do
alias BDS.AI.OneShot alias BDS.AI.OneShot
alias BDS.AI.Runtime alias BDS.AI.Runtime
alias BDS.AI.SecretBackend alias BDS.AI.SecretBackend
alias BDS.MapUtils
import BDS.AI.SettingsStore, import BDS.AI.SettingsStore,
only: [ only: [
@@ -21,20 +22,26 @@ defmodule BDS.AI do
@type endpoint_kind :: atom() @type endpoint_kind :: atom()
@typedoc "Endpoint configuration map." @typedoc "Endpoint configuration map."
@type endpoint :: %{kind: endpoint_kind(), url: String.t() | nil, api_key: String.t() | nil, model: String.t() | nil} @type endpoint :: %{
kind: endpoint_kind(),
url: String.t() | nil,
api_key: String.t() | nil,
model: String.t() | nil
}
@typedoc "Attribute map for endpoint operations." @typedoc "Attribute map for endpoint operations."
@type endpoint_attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} @type endpoint_attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@spec put_endpoint(endpoint_kind(), endpoint_attrs(), keyword()) :: @spec put_endpoint(endpoint_kind(), endpoint_attrs(), keyword()) ::
{:ok, endpoint()} | {:error, term()} {:ok, endpoint()} | {:error, term()}
def put_endpoint(kind, attrs, opts \\ []) when is_atom(kind) and is_map(attrs) and is_list(opts) do def put_endpoint(kind, attrs, opts \\ [])
when is_atom(kind) and is_map(attrs) and is_list(opts) do
backend = Keyword.get(opts, :secret_backend, SecretBackend) backend = Keyword.get(opts, :secret_backend, SecretBackend)
kind_key = Atom.to_string(kind) kind_key = Atom.to_string(kind)
url = Map.get(attrs, :url) || Map.get(attrs, "url") url = MapUtils.attr(attrs, :url)
model = Map.get(attrs, :model) || Map.get(attrs, "model") model = MapUtils.attr(attrs, :model)
api_key = Map.get(attrs, :api_key) || Map.get(attrs, "api_key") api_key = MapUtils.attr(attrs, :api_key)
with :ok <- put_setting("ai.#{kind_key}.url", url), with :ok <- put_setting("ai.#{kind_key}.url", url),
:ok <- put_setting("ai.#{kind_key}.model", model), :ok <- put_setting("ai.#{kind_key}.model", model),
@@ -103,7 +110,8 @@ defmodule BDS.AI do
end end
end end
@spec put_model_preference(atom(), String.t()) :: :ok | {:error, :unknown_model_preference | term()} @spec put_model_preference(atom(), String.t()) ::
:ok | {:error, :unknown_model_preference | term()}
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
case Map.fetch(Runtime.model_preference_keys(), key) do case Map.fetch(Runtime.model_preference_keys(), key) do
{:ok, setting_key} -> put_setting(setting_key, model) {:ok, setting_key} -> put_setting(setting_key, model)
@@ -111,7 +119,8 @@ defmodule BDS.AI do
end end
end end
@spec get_model_preference(atom()) :: {:ok, String.t() | nil} | {:error, :unknown_model_preference} @spec get_model_preference(atom()) ::
{:ok, String.t() | nil} | {:error, :unknown_model_preference}
def get_model_preference(key) when is_atom(key) do def get_model_preference(key) when is_atom(key) do
case Map.fetch(Runtime.model_preference_keys(), key) do case Map.fetch(Runtime.model_preference_keys(), key) do
{:ok, setting_key} -> {:ok, get_setting(setting_key)} {:ok, setting_key} -> {:ok, get_setting(setting_key)}
@@ -134,13 +143,15 @@ defmodule BDS.AI do
@spec analyze_post(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()} @spec analyze_post(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
defdelegate analyze_post(post_input, opts \\ []), to: OneShot defdelegate analyze_post(post_input, opts \\ []), to: OneShot
@spec translate_post(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} @spec translate_post(map() | String.t(), String.t(), keyword()) ::
{:ok, map()} | {:error, term()}
defdelegate translate_post(post_input, target_language, opts \\ []), to: OneShot defdelegate translate_post(post_input, target_language, opts \\ []), to: OneShot
@spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()} @spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
defdelegate analyze_image(media_input, opts \\ []), to: OneShot defdelegate analyze_image(media_input, opts \\ []), to: OneShot
@spec translate_media(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} @spec translate_media(map() | String.t(), String.t(), keyword()) ::
{:ok, map()} | {:error, term()}
defdelegate translate_media(media_input, target_language, opts \\ []), to: OneShot defdelegate translate_media(media_input, target_language, opts \\ []), to: OneShot
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()} @spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}

View File

@@ -2,6 +2,7 @@ defmodule BDS.AI.Catalog do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
import BDS.AI.SettingsStore, import BDS.AI.SettingsStore,
only: [ only: [
get_setting: 1, get_setting: 1,
@@ -21,7 +22,13 @@ defmodule BDS.AI.Catalog do
@spec list_endpoint_models(map(), keyword()) :: {:ok, [map()]} | {:error, term()} @spec list_endpoint_models(map(), keyword()) :: {:ok, [map()]} | {:error, term()}
def list_endpoint_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do def list_endpoint_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
http_client = Keyword.get(opts, :http_client, Application.get_env(:bds, :ai_http_client, BDS.AI.HttpClient)) http_client =
Keyword.get(
opts,
:http_client,
Application.get_env(:bds, :ai_http_client, BDS.AI.HttpClient)
)
OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client) OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client)
end end
@@ -103,8 +110,8 @@ defmodule BDS.AI.Catalog do
@spec put_model_capabilities(String.t(), map()) :: :ok | {:error, term()} @spec put_model_capabilities(String.t(), map()) :: :ok | {:error, term()}
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
capabilities = %{ capabilities = %{
supports_attachment: truthy?(Map.get(attrs, :supports_attachment) || Map.get(attrs, "supports_attachment")), supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)),
supports_tool_calls: truthy?(Map.get(attrs, :supports_tool_calls) || Map.get(attrs, "supports_tool_calls")) supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls))
} }
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities)) put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
@@ -154,7 +161,10 @@ defmodule BDS.AI.Catalog do
} }
end end
@spec model_capabilities(String.t()) :: %{supports_attachment: boolean(), supports_tool_calls: boolean()} @spec model_capabilities(String.t()) :: %{
supports_attachment: boolean(),
supports_tool_calls: boolean()
}
def model_capabilities(model_id) do def model_capabilities(model_id) do
overrides = decode_model_capabilities_override(model_id) overrides = decode_model_capabilities_override(model_id)
@@ -162,7 +172,7 @@ defmodule BDS.AI.Catalog do
case get_catalog_model(model_id) do case get_catalog_model(model_id) do
{:ok, model} -> {:ok, model} ->
%{ %{
supports_attachment: model.supports_attachment or ("image" in model.input_modalities), supports_attachment: model.supports_attachment or "image" in model.input_modalities,
supports_tool_calls: model.supports_tool_calls supports_tool_calls: model.supports_tool_calls
} }
@@ -257,8 +267,19 @@ defmodule BDS.AI.Catalog do
|> Model.changeset(model_attrs) |> Model.changeset(model_attrs)
|> Repo.insert!() |> Repo.insert!()
insert_modalities(provider_id, model_id, Map.get(model_data, "input_modalities", []), :input) insert_modalities(
insert_modalities(provider_id, model_id, Map.get(model_data, "output_modalities", []), :output) provider_id,
model_id,
Map.get(model_data, "input_modalities", []),
:input
)
insert_modalities(
provider_id,
model_id,
Map.get(model_data, "output_modalities", []),
:output
)
inner_count + 1 inner_count + 1
end) end)

View File

@@ -13,6 +13,7 @@ defmodule BDS.AI.Chat do
alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.OpenAICompatibleRuntime
alias BDS.AI.Runtime alias BDS.AI.Runtime
alias BDS.AI.SecretBackend alias BDS.AI.SecretBackend
alias BDS.MapUtils
import BDS.AI.SettingsStore, only: [get_setting: 1] import BDS.AI.SettingsStore, only: [get_setting: 1]
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.Persistence alias BDS.Persistence
@@ -28,15 +29,15 @@ defmodule BDS.AI.Chat do
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()} @spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
def start_chat(attrs \\ %{}) when is_map(attrs) do def start_chat(attrs \\ %{}) when is_map(attrs) do
now = Persistence.now_ms() now = Persistence.now_ms()
model = Map.get(attrs, :model) || Map.get(attrs, "model") model = MapUtils.attr(attrs, :model)
title = Map.get(attrs, :title) || Map.get(attrs, "title") || generated_chat_title(model) title = MapUtils.attr(attrs, :title) || generated_chat_title(model)
%ChatConversation{} %ChatConversation{}
|> ChatConversation.changeset(%{ |> ChatConversation.changeset(%{
id: Ecto.UUID.generate(), id: Ecto.UUID.generate(),
title: title, title: title,
model: model, model: model,
copilot_session_id: Map.get(attrs, :copilot_session_id) || Map.get(attrs, "copilot_session_id"), copilot_session_id: MapUtils.attr(attrs, :copilot_session_id),
created_at: now, created_at: now,
updated_at: now updated_at: now
}) })
@@ -120,7 +121,8 @@ defmodule BDS.AI.Chat do
def send_chat_message(conversation_id, content, opts \\ []) def send_chat_message(conversation_id, content, opts \\ [])
when is_binary(conversation_id) and is_binary(content) and is_list(opts) do when is_binary(conversation_id) and is_binary(content) and is_list(opts) do
with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id), with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id),
{:ok, user_message} <- persist_chat_message(%{ {:ok, user_message} <-
persist_chat_message(%{
conversation_id: conversation.id, conversation_id: conversation.id,
role: :user, role: :user,
content: content, content: content,
@@ -153,7 +155,9 @@ defmodule BDS.AI.Chat do
@spec cancel_chat(String.t()) :: :ok @spec cancel_chat(String.t()) :: :ok
def cancel_chat(conversation_id) when is_binary(conversation_id) do def cancel_chat(conversation_id) when is_binary(conversation_id) do
case InFlight.lookup(conversation_id) do case InFlight.lookup(conversation_id) do
nil -> :ok nil ->
:ok
pid -> pid ->
_ = Task.Supervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid) _ = Task.Supervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid)
:ok :ok
@@ -162,7 +166,11 @@ defmodule BDS.AI.Chat do
@doc false @doc false
def count_distinct_string_list(schema, field, project_id) do def count_distinct_string_list(schema, field, project_id) do
Repo.all(from record in schema, where: field(record, :project_id) == ^project_id, select: field(record, ^field)) Repo.all(
from record in schema,
where: field(record, :project_id) == ^project_id,
select: field(record, ^field)
)
|> List.flatten() |> List.flatten()
|> Enum.reject(&blank?/1) |> Enum.reject(&blank?/1)
|> MapSet.new() |> MapSet.new()
@@ -267,9 +275,14 @@ defmodule BDS.AI.Chat do
normalized_url = String.downcase(url) normalized_url = String.downcase(url)
cond do cond do
String.contains?(normalized_url, "11434") or String.contains?(normalized_url, "ollama") -> "ollama" String.contains?(normalized_url, "11434") or String.contains?(normalized_url, "ollama") ->
String.contains?(normalized_url, "1234") or String.contains?(normalized_url, "lmstudio") -> "lmstudio" "ollama"
true -> "generic-openai"
String.contains?(normalized_url, "1234") or String.contains?(normalized_url, "lmstudio") ->
"lmstudio"
true ->
"generic-openai"
end end
end end
@@ -303,23 +316,58 @@ defmodule BDS.AI.Chat do
:ok <- Runtime.validate_target(:chat, model, mode), :ok <- Runtime.validate_target(:chat, model, mode),
messages <- load_chat_messages(conversation.id), messages <- load_chat_messages(conversation.id),
tools <- available_chat_tools(project_id, model), tools <- available_chat_tools(project_id, model),
{:ok, reply} <- chat_round(conversation, messages, endpoint, model, project_id, tools, runtime, opts, @chat_max_tool_rounds) do {:ok, reply} <-
chat_round(
conversation,
messages,
endpoint,
model,
project_id,
tools,
runtime,
opts,
@chat_max_tool_rounds
) do
{:ok, reply} {:ok, reply}
end end
end end
defp chat_round(_conversation, _messages, _endpoint, _model, _project_id, _tools, _runtime, _opts, 0) do defp chat_round(
_conversation,
_messages,
_endpoint,
_model,
_project_id,
_tools,
_runtime,
_opts,
0
) do
{:error, %{kind: :tool_loop_exhausted}} {:error, %{kind: :tool_loop_exhausted}}
end end
defp chat_round(conversation, messages, endpoint, model, project_id, tools, runtime, opts, rounds_left) do defp chat_round(
conversation,
messages,
endpoint,
model,
project_id,
tools,
runtime,
opts,
rounds_left
) do
request = build_chat_request(conversation, messages, model, project_id, tools) request = build_chat_request(conversation, messages, model, project_id, tools)
with {:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts), with {:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, assistant_message} <- persist_assistant_response(conversation.id, response), {:ok, assistant_message} <- persist_assistant_response(conversation.id, response),
:ok <- touch_conversation(conversation.id) do :ok <- touch_conversation(conversation.id) do
if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do
notify_chat_event(opts, {:chat_streaming_content, conversation.id, Map.get(response, :content)}) notify_chat_event(
opts,
{:chat_streaming_content, conversation.id, Map.get(response, :content)}
)
end end
tool_calls = decode_tool_calls(Map.get(response, :tool_calls)) tool_calls = decode_tool_calls(Map.get(response, :tool_calls))
@@ -330,7 +378,8 @@ defmodule BDS.AI.Chat do
cond do cond do
tool_calls != [] and tools != [] -> tool_calls != [] and tools != [] ->
with {:ok, tool_messages} <- execute_tool_calls(conversation.id, tool_calls, project_id, opts), with {:ok, tool_messages} <-
execute_tool_calls(conversation.id, tool_calls, project_id, opts),
updated_messages <- load_chat_messages(conversation.id), updated_messages <- load_chat_messages(conversation.id),
{:ok, reply} <- {:ok, reply} <-
chat_round( chat_round(
@@ -420,8 +469,13 @@ defmodule BDS.AI.Chat do
defp message_for_runtime(%ChatMessage{} = message) do defp message_for_runtime(%ChatMessage{} = message) do
base = %{"role" => Atom.to_string(message.role)} base = %{"role" => Atom.to_string(message.role)}
base = if is_binary(message.content), do: Map.put(base, "content", message.content), else: base base =
base = if is_binary(message.tool_call_id), do: Map.put(base, "tool_call_id", message.tool_call_id), else: base if is_binary(message.content), do: Map.put(base, "content", message.content), else: base
base =
if is_binary(message.tool_call_id),
do: Map.put(base, "tool_call_id", message.tool_call_id),
else: base
case Catalog.decode_nullable_json(message.tool_calls) do case Catalog.decode_nullable_json(message.tool_calls) do
nil -> base nil -> base
@@ -438,7 +492,9 @@ defmodule BDS.AI.Chat do
[system | remainder] = messages [system | remainder] = messages
{kept, _tokens} = {kept, _tokens} =
Enum.reduce(Enum.reverse(remainder), {[], approximate_message_tokens(system)}, fn message, {acc, used} -> Enum.reduce(Enum.reverse(remainder), {[], approximate_message_tokens(system)}, fn message,
{acc,
used} ->
message_tokens = approximate_message_tokens(message) message_tokens = approximate_message_tokens(message)
if used + message_tokens <= max_budget do if used + message_tokens <= max_budget do
@@ -467,8 +523,12 @@ defmodule BDS.AI.Chat do
defp project_stats_summary(nil), do: nil defp project_stats_summary(nil), do: nil
defp project_stats_summary(project_id) do defp project_stats_summary(project_id) do
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 = count_distinct_string_list(Post, :tags, project_id) tag_count = count_distinct_string_list(Post, :tags, project_id)
category_count = count_distinct_string_list(Post, :categories, project_id) category_count = count_distinct_string_list(Post, :categories, project_id)
@@ -528,9 +588,14 @@ defmodule BDS.AI.Chat do
10 -> {:error, :cancelled} 10 -> {:error, :cancelled}
end end
:shutdown -> {:error, :cancelled} :shutdown ->
{:shutdown, _detail} -> {:error, :cancelled} {:error, :cancelled}
_other -> {:error, :cancelled}
{:shutdown, _detail} ->
{:error, :cancelled}
_other ->
{:error, :cancelled}
end end
end end
end end
@@ -556,14 +621,22 @@ defmodule BDS.AI.Chat do
end end
defp approximate_value_tokens(value) when is_binary(value), do: div(String.length(value), 4) + 1 defp approximate_value_tokens(value) when is_binary(value), do: div(String.length(value), 4) + 1
defp approximate_value_tokens(value) when is_list(value), do: Enum.map(value, &approximate_value_tokens/1) |> Enum.sum()
defp approximate_value_tokens(value) when is_map(value), do: Jason.encode!(value) |> approximate_value_tokens() defp approximate_value_tokens(value) when is_list(value),
do: Enum.map(value, &approximate_value_tokens/1) |> Enum.sum()
defp approximate_value_tokens(value) when is_map(value),
do: Jason.encode!(value) |> approximate_value_tokens()
defp approximate_value_tokens(_value), do: 1 defp approximate_value_tokens(_value), do: 1
defp model_context_window(model_id) do defp model_context_window(model_id) do
case Catalog.get_catalog_model(model_id) do case Catalog.get_catalog_model(model_id) do
{:ok, model} when is_integer(model.context_window) and model.context_window > 0 -> model.context_window {:ok, model} when is_integer(model.context_window) and model.context_window > 0 ->
_other -> @default_context_window model.context_window
_other ->
@default_context_window
end end
end end

View File

@@ -5,6 +5,7 @@ defmodule BDS.AI.OneShot do
alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.OpenAICompatibleRuntime
alias BDS.AI.Runtime alias BDS.AI.Runtime
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.MapUtils
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Repo alias BDS.Repo
@@ -45,10 +46,10 @@ defmodule BDS.AI.OneShot do
def analyze_import_taxonomy(import_terms, existing_terms, opts \\ []) def analyze_import_taxonomy(import_terms, existing_terms, opts \\ [])
when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do
payload = %{ payload = %{
import_categories: normalize_string_list(Map.get(import_terms, :categories) || Map.get(import_terms, "categories")), import_categories: normalize_string_list(MapUtils.attr(import_terms, :categories)),
import_tags: normalize_string_list(Map.get(import_terms, :tags) || Map.get(import_terms, "tags")), import_tags: normalize_string_list(MapUtils.attr(import_terms, :tags)),
existing_categories: normalize_string_list(Map.get(existing_terms, :categories) || Map.get(existing_terms, "categories")), existing_categories: normalize_string_list(MapUtils.attr(existing_terms, :categories)),
existing_tags: normalize_string_list(Map.get(existing_terms, :tags) || Map.get(existing_terms, "tags")) existing_tags: normalize_string_list(MapUtils.attr(existing_terms, :tags))
} }
run_one_shot( run_one_shot(
@@ -96,7 +97,8 @@ defmodule BDS.AI.OneShot do
end end
end end
@spec translate_post(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} @spec translate_post(map() | String.t(), String.t(), keyword()) ::
{:ok, map()} | {:error, term()}
def translate_post(post_input, target_language, opts \\ []) def translate_post(post_input, target_language, opts \\ [])
when is_binary(target_language) and is_list(opts) do when is_binary(target_language) and is_list(opts) do
with {:ok, post} <- normalize_post_input(post_input) do with {:ok, post} <- normalize_post_input(post_input) do
@@ -138,7 +140,8 @@ defmodule BDS.AI.OneShot do
end end
end end
@spec translate_media(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} @spec translate_media(map() | String.t(), String.t(), keyword()) ::
{:ok, map()} | {:error, term()}
def translate_media(media_input, target_language, opts \\ []) def translate_media(media_input, target_language, opts \\ [])
when is_binary(target_language) and is_list(opts) do when is_binary(target_language) and is_list(opts) do
with {:ok, media} <- normalize_media_input(media_input) do with {:ok, media} <- normalize_media_input(media_input) do
@@ -165,7 +168,8 @@ defmodule BDS.AI.OneShot do
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts), with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode), :ok <- Runtime.validate_target(operation, model, mode),
request <- build_one_shot_request(operation, payload, model), request <- build_one_shot_request(operation, payload, model),
{:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts), {:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response), {:ok, json} <- extract_json_response(response),
usage <- Chat.normalize_usage(response.usage), usage <- Chat.normalize_usage(response.usage),
{:ok, result} <- formatter.(json, usage) do {:ok, result} <- formatter.(json, usage) do
@@ -252,7 +256,10 @@ defmodule BDS.AI.OneShot do
defp one_shot_user_content(:analyze_image, media) do defp one_shot_user_content(:analyze_image, media) do
[ [
%{"type" => "text", "text" => "Analyze this image and return title, alt text, and caption."}, %{
"type" => "text",
"text" => "Analyze this image and return title, alt text, and caption."
},
%{"type" => "image_url", "image_url" => %{"url" => media.image_url}} %{"type" => "image_url", "image_url" => %{"url" => media.image_url}}
] ]
end end
@@ -286,9 +293,9 @@ defmodule BDS.AI.OneShot do
defp normalize_post_input(attrs) when is_map(attrs) do defp normalize_post_input(attrs) when is_map(attrs) do
{:ok, {:ok,
%{ %{
title: Map.get(attrs, :title) || Map.get(attrs, "title") || "", title: MapUtils.attr(attrs, :title) || "",
excerpt: Map.get(attrs, :excerpt) || Map.get(attrs, "excerpt") || "", excerpt: MapUtils.attr(attrs, :excerpt) || "",
content: Map.get(attrs, :content) || Map.get(attrs, "content") || "" content: MapUtils.attr(attrs, :content) || ""
}} }}
end end
@@ -313,11 +320,11 @@ defmodule BDS.AI.OneShot do
defp normalize_media_input(attrs) when is_map(attrs) do defp normalize_media_input(attrs) when is_map(attrs) do
{:ok, {:ok,
%{ %{
mime_type: Map.get(attrs, :mime_type) || Map.get(attrs, "mime_type"), mime_type: MapUtils.attr(attrs, :mime_type),
title: Map.get(attrs, :title) || Map.get(attrs, "title") || "", title: MapUtils.attr(attrs, :title) || "",
alt: Map.get(attrs, :alt) || Map.get(attrs, "alt") || "", alt: MapUtils.attr(attrs, :alt) || "",
caption: Map.get(attrs, :caption) || Map.get(attrs, "caption") || "", caption: MapUtils.attr(attrs, :caption) || "",
image_url: Map.get(attrs, :image_url) || Map.get(attrs, "image_url") image_url: MapUtils.attr(attrs, :image_url)
}} }}
end end
@@ -336,7 +343,8 @@ defmodule BDS.AI.OneShot do
|> Enum.uniq() |> Enum.uniq()
end end
defp filter_taxonomy_mapping_response(mappings, import_terms, existing_terms) when is_map(mappings) do defp filter_taxonomy_mapping_response(mappings, import_terms, existing_terms)
when is_map(mappings) do
import_lookup = canonical_term_lookup(import_terms) import_lookup = canonical_term_lookup(import_terms)
existing_lookup = canonical_term_lookup(existing_terms) existing_lookup = canonical_term_lookup(existing_terms)

View File

@@ -80,18 +80,26 @@ defmodule BDS.Desktop.ShellCommands do
attrs = %{group_id: group_id, group_name: "Search"} attrs = %{group_id: group_id, group_name: "Search"}
{:ok, posts_task} = {:ok, posts_task} =
Tasks.submit_task("Reindex Search Text", fn report -> Tasks.submit_task(
"Reindex Search Text",
fn report ->
:ok = Search.reindex_posts(project.id, on_progress: report) :ok = Search.reindex_posts(project.id, on_progress: report)
report.(1.0, "Post search text reindexed") report.(1.0, "Post search text reindexed")
%{project_id: project.id, entity: "posts"} %{project_id: project.id, entity: "posts"}
end, attrs) end,
attrs
)
{:ok, _media_task} = {:ok, _media_task} =
Tasks.submit_task("Reindex Media Search Text", fn report -> Tasks.submit_task(
"Reindex Media Search Text",
fn report ->
:ok = Search.reindex_media(project.id, on_progress: report) :ok = Search.reindex_media(project.id, on_progress: report)
report.(1.0, "Media search text reindexed") report.(1.0, "Media search text reindexed")
%{project_id: project.id, entity: "media"} %{project_id: project.id, entity: "media"}
end, attrs) end,
attrs
)
{:ok, {:ok,
%{ %{
@@ -107,43 +115,86 @@ defmodule BDS.Desktop.ShellCommands do
end end
defp dispatch("rebuild_embedding_index", project, _params) do defp dispatch("rebuild_embedding_index", project, _params) do
queue_task(project, "rebuild_embedding_index", "Rebuild Embedding Index", "Embeddings", fn report -> queue_task(
project,
"rebuild_embedding_index",
"Rebuild Embedding Index",
"Embeddings",
fn report ->
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report) {:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
report.(1.0, "Embedding index rebuilt") report.(1.0, "Embedding index rebuilt")
%{project_id: project.id, rebuilt_post_ids: rebuilt_post_ids, rebuilt_count: length(rebuilt_post_ids)}
end) %{
project_id: project.id,
rebuilt_post_ids: rebuilt_post_ids,
rebuilt_count: length(rebuilt_post_ids)
}
end
)
end end
defp dispatch("rebuild_posts_from_files", project, _params) do defp dispatch("rebuild_posts_from_files", project, _params) do
queue_task(project, "rebuild_posts_from_files", "Rebuild Posts From Files", "Maintenance", fn report -> queue_task(
{:ok, posts} = Maintenance.rebuild_from_filesystem(project.id, "post", on_progress: report) project,
"rebuild_posts_from_files",
"Rebuild Posts From Files",
"Maintenance",
fn report ->
{:ok, posts} =
Maintenance.rebuild_from_filesystem(project.id, "post", on_progress: report)
report.(1.0, "Post rebuild complete") report.(1.0, "Post rebuild complete")
%{project_id: project.id, counts: %{posts: length(posts)}} %{project_id: project.id, counts: %{posts: length(posts)}}
end) end
)
end end
defp dispatch("rebuild_media_from_files", project, _params) do defp dispatch("rebuild_media_from_files", project, _params) do
queue_task(project, "rebuild_media_from_files", "Rebuild Media From Files", "Maintenance", fn report -> queue_task(
{:ok, media} = Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report) project,
"rebuild_media_from_files",
"Rebuild Media From Files",
"Maintenance",
fn report ->
{:ok, media} =
Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
report.(1.0, "Media rebuild complete") report.(1.0, "Media rebuild complete")
%{project_id: project.id, counts: %{media: length(media)}} %{project_id: project.id, counts: %{media: length(media)}}
end) end
)
end end
defp dispatch("rebuild_scripts_from_files", project, _params) do defp dispatch("rebuild_scripts_from_files", project, _params) do
queue_task(project, "rebuild_scripts_from_files", "Rebuild Scripts From Files", "Maintenance", fn report -> queue_task(
{:ok, scripts} = Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report) project,
"rebuild_scripts_from_files",
"Rebuild Scripts From Files",
"Maintenance",
fn report ->
{:ok, scripts} =
Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
report.(1.0, "Script rebuild complete") report.(1.0, "Script rebuild complete")
%{project_id: project.id, counts: %{scripts: length(scripts)}} %{project_id: project.id, counts: %{scripts: length(scripts)}}
end) end
)
end end
defp dispatch("rebuild_templates_from_files", project, _params) do defp dispatch("rebuild_templates_from_files", project, _params) do
queue_task(project, "rebuild_templates_from_files", "Rebuild Templates From Files", "Maintenance", fn report -> queue_task(
{:ok, templates} = Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report) project,
"rebuild_templates_from_files",
"Rebuild Templates From Files",
"Maintenance",
fn report ->
{:ok, templates} =
Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
report.(1.0, "Template rebuild complete") report.(1.0, "Template rebuild complete")
%{project_id: project.id, counts: %{templates: length(templates)}} %{project_id: project.id, counts: %{templates: length(templates)}}
end) end
)
end end
defp dispatch("rebuild_post_links", project, _params) do defp dispatch("rebuild_post_links", project, _params) do
@@ -155,11 +206,17 @@ defmodule BDS.Desktop.ShellCommands do
end end
defp dispatch("regenerate_missing_thumbnails", project, _params) do defp dispatch("regenerate_missing_thumbnails", project, _params) do
queue_task(project, "regenerate_missing_thumbnails", "Regenerate Missing Thumbnails", "Maintenance", fn report -> queue_task(
project,
"regenerate_missing_thumbnails",
"Regenerate Missing Thumbnails",
"Maintenance",
fn report ->
result = BDS.Media.regenerate_missing_thumbnails(project.id, on_progress: report) result = BDS.Media.regenerate_missing_thumbnails(project.id, on_progress: report)
report.(1.0, "Missing thumbnails regenerated") report.(1.0, "Missing thumbnails regenerated")
Map.put(result, :project_id, project.id) Map.put(result, :project_id, project.id)
end) end
)
end end
defp dispatch("rebuild_database", project, _params) do defp dispatch("rebuild_database", project, _params) do
@@ -192,15 +249,24 @@ defmodule BDS.Desktop.ShellCommands do
defp dispatch("generate_sitemap", project, _params) do defp dispatch("generate_sitemap", project, _params) do
queue_task(project, "generate_sitemap", "Generate Site", "Generation", fn report -> queue_task(project, "generate_sitemap", "Generate Site", "Generation", fn report ->
{:ok, generation} = Generation.generate_site(project.id, @site_sections, on_progress: report) {:ok, generation} =
Generation.generate_site(project.id, @site_sections, on_progress: report)
report.(1.0, "Generated site output") report.(1.0, "Generated site output")
%{project_id: project.id, sections: generation.sections, generated_count: length(generation.generated_files)}
%{
project_id: project.id,
sections: generation.sections,
generated_count: length(generation.generated_files)
}
end) end)
end end
defp dispatch("validate_site", project, _params) do defp dispatch("validate_site", project, _params) do
queue_task(project, "validate_site", "Validate Site", "Validation", fn report -> queue_task(project, "validate_site", "Validate Site", "Validation", fn report ->
{:ok, validation} = Generation.validate_site(project.id, @site_sections, on_progress: report) {:ok, validation} =
Generation.validate_site(project.id, @site_sections, on_progress: report)
site_validation_result(project.id, validation) site_validation_result(project.id, validation)
end) end)
end end
@@ -214,13 +280,18 @@ defmodule BDS.Desktop.ShellCommands do
end end
defp dispatch("repair_metadata_diff", project, params) do defp dispatch("repair_metadata_diff", project, params) do
items = normalize_metadata_diff_items(Map.get(params, "items", Map.get(params, :items, []))) items = normalize_metadata_diff_items(BDS.MapUtils.attr(params, :items, []))
direction = Map.get(params, "direction", Map.get(params, :direction)) direction = BDS.MapUtils.attr(params, :direction)
if items == [] do if items == [] do
{:error, %{action: "repair_metadata_diff", message: "No metadata diff items selected"}} {:error, %{action: "repair_metadata_diff", message: "No metadata diff items selected"}}
else else
queue_task(project, "repair_metadata_diff", "Repair Metadata Diff", "Maintenance", fn report -> queue_task(
project,
"repair_metadata_diff",
"Repair Metadata Diff",
"Maintenance",
fn report ->
{:ok, _repair} = {:ok, _repair} =
Maintenance.repair_metadata_diff(project.id, direction, items, Maintenance.repair_metadata_diff(project.id, direction, items,
on_progress: scaled_progress_reporter(report, 0.0, 0.8) on_progress: scaled_progress_reporter(report, 0.0, 0.8)
@@ -233,17 +304,23 @@ defmodule BDS.Desktop.ShellCommands do
report.(1.0, "Metadata diff repair complete") report.(1.0, "Metadata diff repair complete")
metadata_diff_result(project.id, metadata_diff) metadata_diff_result(project.id, metadata_diff)
end) end
)
end end
end end
defp dispatch("import_metadata_diff_orphans", project, params) do defp dispatch("import_metadata_diff_orphans", project, params) do
orphans = normalize_metadata_diff_orphans(Map.get(params, "orphans", Map.get(params, :orphans, []))) orphans = normalize_metadata_diff_orphans(BDS.MapUtils.attr(params, :orphans, []))
if orphans == [] do if orphans == [] do
{:error, %{action: "import_metadata_diff_orphans", message: "No orphan files selected"}} {:error, %{action: "import_metadata_diff_orphans", message: "No orphan files selected"}}
else else
queue_task(project, "import_metadata_diff_orphans", "Import Metadata Diff Orphans", "Maintenance", fn report -> queue_task(
project,
"import_metadata_diff_orphans",
"Import Metadata Diff Orphans",
"Maintenance",
fn report ->
{:ok, _import} = {:ok, _import} =
Maintenance.import_metadata_diff_orphans(project.id, orphans, Maintenance.import_metadata_diff_orphans(project.id, orphans,
on_progress: scaled_progress_reporter(report, 0.0, 0.8) on_progress: scaled_progress_reporter(report, 0.0, 0.8)
@@ -256,16 +333,23 @@ defmodule BDS.Desktop.ShellCommands do
report.(1.0, "Metadata diff import complete") report.(1.0, "Metadata diff import complete")
metadata_diff_result(project.id, metadata_diff) metadata_diff_result(project.id, metadata_diff)
end) end
)
end end
end end
defp dispatch("validate_translations", project, _params) do defp dispatch("validate_translations", project, _params) do
queue_task(project, "validate_translations", "Validate Translations", "Validation", fn report -> queue_task(
project,
"validate_translations",
"Validate Translations",
"Validation",
fn report ->
{:ok, translation_report} = Posts.validate_translations(project.id, on_progress: report) {:ok, translation_report} = Posts.validate_translations(project.id, on_progress: report)
report.(1.0, "Translation validation complete") report.(1.0, "Translation validation complete")
translation_validation_result(project.id, translation_report) translation_validation_result(project.id, translation_report)
end) end
)
end end
defp dispatch("find_duplicates", project, _params) do defp dispatch("find_duplicates", project, _params) do
@@ -342,7 +426,9 @@ defmodule BDS.Desktop.ShellCommands do
%{ %{
name: "Rebuild Media From Files", name: "Rebuild Media From Files",
work: fn report -> work: fn report ->
{:ok, media} = Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report) {:ok, media} =
Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
report.(1.0, "Media rebuild complete") report.(1.0, "Media rebuild complete")
%{project_id: project.id, counts: %{media: length(media)}} %{project_id: project.id, counts: %{media: length(media)}}
end end
@@ -350,7 +436,9 @@ defmodule BDS.Desktop.ShellCommands do
%{ %{
name: "Rebuild Scripts From Files", name: "Rebuild Scripts From Files",
work: fn report -> work: fn report ->
{:ok, scripts} = Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report) {:ok, scripts} =
Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
report.(1.0, "Script rebuild complete") report.(1.0, "Script rebuild complete")
%{project_id: project.id, counts: %{scripts: length(scripts)}} %{project_id: project.id, counts: %{scripts: length(scripts)}}
end end
@@ -358,7 +446,9 @@ defmodule BDS.Desktop.ShellCommands do
%{ %{
name: "Rebuild Templates From Files", name: "Rebuild Templates From Files",
work: fn report -> work: fn report ->
{:ok, templates} = Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report) {:ok, templates} =
Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
report.(1.0, "Template rebuild complete") report.(1.0, "Template rebuild complete")
%{project_id: project.id, counts: %{templates: length(templates)}} %{project_id: project.id, counts: %{templates: length(templates)}}
end end
@@ -384,7 +474,12 @@ defmodule BDS.Desktop.ShellCommands do
work: fn report -> work: fn report ->
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report) {:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
report.(1.0, "Embedding index rebuilt") report.(1.0, "Embedding index rebuilt")
%{project_id: project.id, rebuilt_post_ids: rebuilt_post_ids, rebuilt_count: length(rebuilt_post_ids)}
%{
project_id: project.id,
rebuilt_post_ids: rebuilt_post_ids,
rebuilt_count: length(rebuilt_post_ids)
}
end end
} }
] ]
@@ -531,7 +626,10 @@ defmodule BDS.Desktop.ShellCommands do
subtitle: "Database rows and translation files checked for invalid state", subtitle: "Database rows and translation files checked for invalid state",
editorMeta: [ editorMeta: [
%{label: "Invalid DB", value: Integer.to_string(length(report.invalid_database_rows))}, %{label: "Invalid DB", value: Integer.to_string(length(report.invalid_database_rows))},
%{label: "Invalid Files", value: Integer.to_string(length(report.invalid_filesystem_files))} %{
label: "Invalid Files",
value: Integer.to_string(length(report.invalid_filesystem_files))
}
], ],
payload: normalize_translation_validation(report) payload: normalize_translation_validation(report)
} }
@@ -564,8 +662,8 @@ defmodule BDS.Desktop.ShellCommands do
defp normalize_metadata_diff_items(items) when is_list(items) do defp normalize_metadata_diff_items(items) when is_list(items) do
Enum.map(items, fn item -> Enum.map(items, fn item ->
%{ %{
entity_type: Map.get(item, :entity_type) || Map.get(item, "entity_type"), entity_type: BDS.MapUtils.attr(item, :entity_type),
entity_id: Map.get(item, :entity_id) || Map.get(item, "entity_id") entity_id: BDS.MapUtils.attr(item, :entity_id)
} }
end) end)
end end
@@ -574,7 +672,7 @@ defmodule BDS.Desktop.ShellCommands do
defp normalize_metadata_diff_orphans(orphans) when is_list(orphans) do defp normalize_metadata_diff_orphans(orphans) when is_list(orphans) do
Enum.map(orphans, fn orphan -> Enum.map(orphans, fn orphan ->
%{file_path: Map.get(orphan, :file_path) || Map.get(orphan, "file_path")} %{file_path: BDS.MapUtils.attr(orphan, :file_path)}
end) end)
end end
@@ -593,7 +691,10 @@ defmodule BDS.Desktop.ShellCommands do
ssh_mode: Map.get(prefs, "ssh_mode") ssh_mode: Map.get(prefs, "ssh_mode")
} }
if Enum.all?([credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path], &is_binary/1) do if Enum.all?(
[credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path],
&is_binary/1
) do
{:ok, credentials} {:ok, credentials}
else else
{:error, %{action: "upload_site", message: "Publishing preferences are incomplete"}} {:error, %{action: "upload_site", message: "Publishing preferences are incomplete"}}

View File

@@ -4,12 +4,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
@tool_args_max_length 30 @tool_args_max_length 30
def tool_call_name(tool_call) when is_map(tool_call) do def tool_call_name(tool_call) when is_map(tool_call) do
Map.get(tool_call, "name") || Map.get(tool_call, :name) || "tool" BDS.MapUtils.attr(tool_call, :name) || "tool"
end end
def tool_call_arguments(tool_call) when is_map(tool_call) do def tool_call_arguments(tool_call) when is_map(tool_call) do
Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{}
end end
def normalize_tool_calls(tool_calls) when is_list(tool_calls) do def normalize_tool_calls(tool_calls) when is_list(tool_calls) do
@@ -17,7 +16,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
arguments = tool_call_arguments(tool_call) arguments = tool_call_arguments(tool_call)
%{ %{
id: Map.get(tool_call, "id") || Map.get(tool_call, :id), id: BDS.MapUtils.attr(tool_call, :id),
name: tool_call_name(tool_call), name: tool_call_name(tool_call),
arguments: arguments, arguments: arguments,
args_preview: tool_arguments_preview(arguments), args_preview: tool_arguments_preview(arguments),

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.CliSync do
import Phoenix.Component, only: [assign: 3] import Phoenix.Component, only: [assign: 3]
alias BDS.{Media, Posts} alias BDS.{Media, Posts}
alias BDS.MapUtils
alias BDS.Media.Media, as: MediaRecord alias BDS.Media.Media, as: MediaRecord
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.UI.Workbench alias BDS.UI.Workbench
@@ -12,17 +13,18 @@ defmodule BDS.Desktop.ShellLive.CliSync do
Apply a CLI entity change payload to the shell socket. `reload_fun` is Apply a CLI entity change payload to the shell socket. `reload_fun` is
called with `(socket, workbench)` to refresh derived data. called with `(socket, workbench)` to refresh derived data.
""" """
@spec apply_entity_change(Phoenix.LiveView.Socket.t(), map(), @spec apply_entity_change(Phoenix.LiveView.Socket.t(), map(), (Phoenix.LiveView.Socket.t(),
(Phoenix.LiveView.Socket.t(), map() -> Phoenix.LiveView.Socket.t())) :: map() ->
Phoenix.LiveView.Socket.t())) ::
Phoenix.LiveView.Socket.t() Phoenix.LiveView.Socket.t()
def apply_entity_change(socket, payload, reload_fun) do def apply_entity_change(socket, payload, reload_fun) do
entity = Map.get(payload, :entity) || Map.get(payload, "entity") || Map.get(payload, :entity_type) || Map.get(payload, "entity_type") entity = MapUtils.attr(payload, :entity) || MapUtils.attr(payload, :entity_type)
entity_id = entity_id =
Map.get(payload, :entity_id) || Map.get(payload, "entity_id") || Map.get(payload, :entityId) || MapUtils.attr(payload, :entity_id) || Map.get(payload, :entityId) ||
Map.get(payload, "entityId") Map.get(payload, "entityId")
action = normalize_action(Map.get(payload, :action) || Map.get(payload, "action")) action = normalize_action(MapUtils.attr(payload, :action))
if is_binary(entity) and entity != "" and is_binary(entity_id) and entity_id != "" and if is_binary(entity) and entity != "" and is_binary(entity_id) and entity_id != "" and
action in [:created, :updated, :deleted] do action in [:created, :updated, :deleted] do
@@ -45,13 +47,28 @@ defmodule BDS.Desktop.ShellLive.CliSync do
|> assign(:shell_overlay, nil) |> assign(:shell_overlay, nil)
|> 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)
)
{socket, workbench} {socket, workbench}
end end
@@ -65,31 +82,58 @@ defmodule BDS.Desktop.ShellLive.CliSync 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)
)
{socket, workbench} {socket, workbench}
end end
defp maybe_close_deleted_tab(socket, _entity, _entity_id, _action), do: {socket, socket.assigns.workbench} defp maybe_close_deleted_tab(socket, _entity, _entity_id, _action),
do: {socket, socket.assigns.workbench}
defp maybe_refresh_tab_meta(socket, "post", post_id, action) when action in [:created, :updated] do defp maybe_refresh_tab_meta(socket, "post", post_id, action)
when action in [:created, :updated] do
maybe_put_tab_meta(socket, :post, post_id, fn -> maybe_put_tab_meta(socket, :post, post_id, fn ->
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
%Post{} = post -> %{title: post.title || post.slug || post.id, subtitle: Atom.to_string(post.status)} %Post{} = post ->
_other -> nil %{title: post.title || post.slug || post.id, subtitle: Atom.to_string(post.status)}
_other ->
nil
end end
end) end)
end end
defp maybe_refresh_tab_meta(socket, "media", media_id, action) when action in [:created, :updated] do defp maybe_refresh_tab_meta(socket, "media", media_id, action)
when action in [:created, :updated] do
maybe_put_tab_meta(socket, :media, media_id, fn -> maybe_put_tab_meta(socket, :media, media_id, fn ->
case Media.get_media(media_id) do case Media.get_media(media_id) do
%MediaRecord{} = media -> %{title: media.title || media.filename || media.id, subtitle: media.filename || media.mime_type || "media"} %MediaRecord{} = media ->
_other -> nil %{
title: media.title || media.filename || media.id,
subtitle: media.filename || media.mime_type || "media"
}
_other ->
nil
end end
end) end)
end end
@@ -102,7 +146,9 @@ defmodule BDS.Desktop.ShellLive.CliSync do
if tab_present?(socket.assigns.workbench, key) or Map.has_key?(socket.assigns.tab_meta, key) do if tab_present?(socket.assigns.workbench, key) or Map.has_key?(socket.assigns.tab_meta, key) do
case meta_fun.() do case meta_fun.() do
%{} = fresh_meta -> %{} = fresh_meta ->
updated_meta = Map.update(socket.assigns.tab_meta, key, fresh_meta, &Map.merge(&1, fresh_meta)) updated_meta =
Map.update(socket.assigns.tab_meta, key, fresh_meta, &Map.merge(&1, fresh_meta))
assign(socket, :tab_meta, updated_meta) assign(socket, :tab_meta, updated_meta)
_other -> _other ->

View File

@@ -7,11 +7,18 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
alias BDS.{Embeddings, Generation, Git, Posts, Repo} alias BDS.{Embeddings, Generation, Git, Posts, Repo}
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.MapUtils
alias BDS.Settings.Setting alias BDS.Settings.Setting
embed_templates "misc_editor_html/*" embed_templates("misc_editor_html/*")
@misc_routes [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] @misc_routes [
:site_validation,
:metadata_diff,
:translation_validation,
:find_duplicates,
:git_diff
]
def assign_socket(socket) do def assign_socket(socket) do
assign(socket, :misc_editor, build(socket.assigns)) assign(socket, :misc_editor, build(socket.assigns))
@@ -19,7 +26,9 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
def rerun(socket) do def rerun(socket) do
case meta(socket.assigns) do case meta(socket.assigns) do
%{action: action} when is_binary(action) -> {:command, action} %{action: action} when is_binary(action) ->
{:command, action}
_other -> _other ->
case misc_route_action(socket.assigns.current_tab.type) do case misc_route_action(socket.assigns.current_tab.type) do
nil -> {:noop, socket} nil -> {:noop, socket}
@@ -47,10 +56,16 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
{:ok, result} -> {:ok, result} ->
{:rerun, {:rerun,
socket socket
|> append_output.(translated("Site Validation"), translated("Validation changes applied"), inspect(result))} |> append_output.(
translated("Site Validation"),
translated("Validation changes applied"),
inspect(result)
)}
end end
rescue rescue
error -> {:socket, append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")} error ->
{:socket,
append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")}
end end
def toggle_duplicate(socket, pair_id, reload) do def toggle_duplicate(socket, pair_id, reload) do
@@ -65,7 +80,10 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
socket socket
|> assign(:misc_editor_selected_pairs, Map.put(selected_by_tab, socket.assigns.current_tab.id, next)) |> assign(
:misc_editor_selected_pairs,
Map.put(selected_by_tab, socket.assigns.current_tab.id, next)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@@ -75,7 +93,9 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
socket socket
|> update_payload(fn payload -> |> update_payload(fn payload ->
update_in(payload[:pairs], fn pairs -> update_in(payload[:pairs], fn pairs ->
Enum.reject(pairs || [], fn pair -> pair_identity(pair) == pair_id(post_id_a, post_id_b) end) Enum.reject(pairs || [], fn pair ->
pair_identity(pair) == pair_id(post_id_a, post_id_b)
end)
end) end)
end) end)
|> clear_selected_pair(pair_id(post_id_a, post_id_b)) |> clear_selected_pair(pair_id(post_id_a, post_id_b))
@@ -91,6 +111,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
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
selected = selected =
socket.assigns.misc_editor_selected_pairs socket.assigns.misc_editor_selected_pairs
|> Map.get(tab_id, MapSet.new()) |> Map.get(tab_id, MapSet.new())
@@ -106,7 +127,10 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
Enum.reject(pairs || [], fn pair -> pair_identity(pair) in selected end) Enum.reject(pairs || [], fn pair -> pair_identity(pair) in selected end)
end) end)
end) end)
|> assign(:misc_editor_selected_pairs, Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new())) |> assign(
:misc_editor_selected_pairs,
Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new())
)
|> append_output.(translated("Find Duplicates"), translated("Selected pairs dismissed")) |> append_output.(translated("Find Duplicates"), translated("Selected pairs dismissed"))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
@@ -137,14 +161,20 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
}) })
)} )}
rescue rescue
error -> {:socket, append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")} error ->
{:socket,
append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")}
end end
def select_git_diff_file(socket, file_path) do def select_git_diff_file(socket, file_path) do
assign( assign(
socket, socket,
:misc_editor_git_selected_files, :misc_editor_git_selected_files,
Map.put(socket.assigns.misc_editor_git_selected_files, socket.assigns.current_tab.id, file_path) Map.put(
socket.assigns.misc_editor_git_selected_files,
socket.assigns.current_tab.id,
file_path
)
) )
end end
@@ -183,7 +213,10 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
meta = meta(socket.assigns) meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{}) payload = Map.get(meta, :payload, %{})
items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1) items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
orphan_files = Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
orphan_files =
Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
tabs = metadata_diff_tabs(items, orphan_files) tabs = metadata_diff_tabs(items, orphan_files)
active_tab = metadata_diff_active_tab(socket.assigns, tabs) active_tab = metadata_diff_active_tab(socket.assigns, tabs)
@@ -214,7 +247,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
def build(_assigns), do: nil def build(_assigns), do: nil
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 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"
@@ -255,11 +289,17 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp build_metadata_diff(assigns, meta, payload) do defp build_metadata_diff(assigns, meta, payload) do
items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1) items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
orphan_files = Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
orphan_files =
Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
tabs = metadata_diff_tabs(items, orphan_files) tabs = metadata_diff_tabs(items, orphan_files)
active_tab = metadata_diff_active_tab(assigns, tabs) active_tab = metadata_diff_active_tab(assigns, tabs)
active_field = metadata_diff_active_field(assigns) active_field = metadata_diff_active_field(assigns)
current_tab = Enum.find(tabs, &(&1.id == active_tab)) || List.first(tabs) || empty_metadata_diff_tab()
current_tab =
Enum.find(tabs, &(&1.id == active_tab)) || List.first(tabs) || empty_metadata_diff_tab()
filtered_items = metadata_diff_filtered_items(current_tab.items, active_field) filtered_items = metadata_diff_filtered_items(current_tab.items, active_field)
%{ %{
@@ -267,7 +307,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
title: Map.get(meta, :title, translated("Metadata Diff")), title: Map.get(meta, :title, translated("Metadata Diff")),
subtitle: Map.get(meta, :subtitle, ""), subtitle: Map.get(meta, :subtitle, ""),
summary: Map.get(payload, :summary, %{}), summary: Map.get(payload, :summary, %{}),
tabs: Enum.map(tabs, &Map.take(&1, [:id, :label, :badge_count, :diff_count, :orphan_count])), tabs:
Enum.map(tabs, &Map.take(&1, [:id, :label, :badge_count, :diff_count, :orphan_count])),
active_tab: current_tab.id, active_tab: current_tab.id,
active_field: active_field, active_field: active_field,
repair_enabled: metadata_diff_repairable_tab?(current_tab.id), repair_enabled: metadata_diff_repairable_tab?(current_tab.id),
@@ -300,7 +341,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
defp build_duplicates(assigns, meta, payload) do defp build_duplicates(assigns, meta, payload) do
selected_pairs = Map.get(assigns.misc_editor_selected_pairs, assigns.current_tab.id, MapSet.new()) selected_pairs =
Map.get(assigns.misc_editor_selected_pairs, assigns.current_tab.id, MapSet.new())
%{ %{
kind: :find_duplicates, kind: :find_duplicates,
@@ -319,8 +361,15 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
{files, diff, error_message} = {files, diff, error_message} =
case Git.status(project_id) do case Git.status(project_id) do
{:ok, %{files: files}} -> {:ok, %{files: files}} ->
file_paths = files |> Enum.map(&Map.get(&1, :path)) |> Enum.reject(&is_nil/1) |> Enum.uniq() |> Enum.sort() file_paths =
selected_file_path = select_git_diff_path(assigns.current_tab.id, file_paths, selected_files) files
|> Enum.map(&Map.get(&1, :path))
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> Enum.sort()
selected_file_path =
select_git_diff_path(assigns.current_tab.id, file_paths, selected_files)
diff = diff =
case selected_file_path do case selected_file_path do
@@ -329,8 +378,14 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
file_path -> file_path ->
case Git.get_diff_content(project_id, file_path) do case Git.get_diff_content(project_id, file_path) do
{:ok, diff} -> diff {:ok, diff} ->
{:error, reason} -> Map.merge(empty_git_diff(project_id), %{file_path: file_path, error: inspect(reason)}) diff
{:error, reason} ->
Map.merge(empty_git_diff(project_id), %{
file_path: file_path,
error: inspect(reason)
})
end end
end end
@@ -357,10 +412,17 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
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" -> translated("translationValidation.issue.sameLanguage") "same-language-as-canonical" ->
"do-not-translate-has-translations" -> translated("translationValidation.issue.doNotTranslate") translated("translationValidation.issue.sameLanguage")
"content-in-database" -> translated("translationValidation.issue.contentInDatabase")
_other -> translated("translationValidation.issue.missingSource") "do-not-translate-has-translations" ->
translated("translationValidation.issue.doNotTranslate")
"content-in-database" ->
translated("translationValidation.issue.contentInDatabase")
_other ->
translated("translationValidation.issue.missingSource")
end end
end end
@@ -414,12 +476,17 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp clear_selected_pair(socket, pair_id) do defp clear_selected_pair(socket, pair_id) do
tab_id = socket.assigns.current_tab.id tab_id = socket.assigns.current_tab.id
current = Map.get(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new()) current = Map.get(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new())
next_pairs = Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.delete(current, pair_id))
next_pairs =
Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.delete(current, pair_id))
assign(socket, :misc_editor_selected_pairs, next_pairs) assign(socket, :misc_editor_selected_pairs, next_pairs)
end end
defp pair_id(post_id_a, post_id_b), do: Enum.sort([post_id_a, post_id_b]) |> Enum.join("::") defp pair_id(post_id_a, post_id_b), do: Enum.sort([post_id_a, post_id_b]) |> Enum.join("::")
defp pair_identity(pair), do: pair_id(Map.get(pair, :post_id_a) || Map.get(pair, "post_id_a"), Map.get(pair, :post_id_b) || Map.get(pair, "post_id_b"))
defp pair_identity(pair),
do: pair_id(MapUtils.attr(pair, :post_id_a), MapUtils.attr(pair, :post_id_b))
defp decode_pair_id(encoded) when is_binary(encoded) do defp decode_pair_id(encoded) when is_binary(encoded) do
case String.split(encoded, "::", parts: 2) do case String.split(encoded, "::", parts: 2) do
@@ -432,7 +499,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp field_summaries(items) do defp field_summaries(items) do
items items
|> Enum.flat_map(fn item -> Map.get(item, :differences) || Map.get(item, "differences") || [] end) |> Enum.flat_map(fn item -> MapUtils.attr(item, :differences) || [] end)
|> Enum.group_by(&diff_name/1) |> Enum.group_by(&diff_name/1)
|> Enum.map(fn {field, diffs} -> %{field_name: field, diff_count: length(diffs)} end) |> Enum.map(fn {field, diffs} -> %{field_name: field, diff_count: length(diffs)} end)
|> Enum.sort_by(&{&1.diff_count * -1, &1.field_name}) |> Enum.sort_by(&{&1.diff_count * -1, &1.field_name})
@@ -465,7 +532,15 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
defp empty_metadata_diff_tab do defp empty_metadata_diff_tab do
%{id: "posts", label: translated("Posts"), items: [], orphan_files: [], diff_count: 0, orphan_count: 0, badge_count: 0} %{
id: "posts",
label: translated("Posts"),
items: [],
orphan_files: [],
diff_count: 0,
orphan_count: 0,
badge_count: 0
}
end end
defp metadata_diff_active_tab(assigns, tabs) do defp metadata_diff_active_tab(assigns, tabs) do
@@ -489,11 +564,12 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
defp normalize_metadata_diff_item(item) do defp normalize_metadata_diff_item(item) do
entity_type = Map.get(item, :entity_type) || Map.get(item, "entity_type") || "post" entity_type = MapUtils.attr(item, :entity_type) || "post"
entity_id = Map.get(item, :entity_id) || Map.get(item, "entity_id") || "" entity_id = MapUtils.attr(item, :entity_id) || ""
differences = differences =
item item
|> Map.get(:differences, Map.get(item, "differences", [])) |> MapUtils.attr(:differences, [])
|> Enum.map(&normalize_metadata_diff_difference/1) |> Enum.map(&normalize_metadata_diff_difference/1)
%{ %{
@@ -510,30 +586,31 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp normalize_metadata_diff_difference(diff) do defp normalize_metadata_diff_difference(diff) do
%{ %{
field: diff_name(diff), field: diff_name(diff),
db_value: format_metadata_diff_value(Map.get(diff, :db_value) || Map.get(diff, "db_value")), db_value: format_metadata_diff_value(MapUtils.attr(diff, :db_value)),
file_value: format_metadata_diff_value(Map.get(diff, :file_value) || Map.get(diff, "file_value")) file_value: format_metadata_diff_value(MapUtils.attr(diff, :file_value))
} }
end end
defp normalize_metadata_diff_orphan(orphan) do defp normalize_metadata_diff_orphan(orphan) do
path = Map.get(orphan, :file_path) || Map.get(orphan, "file_path") || Map.get(orphan, :path) || Map.get(orphan, "path") || "" path = MapUtils.attr(orphan, :file_path) || MapUtils.attr(orphan, :path) || ""
entity_type = Map.get(orphan, :entity_type) || Map.get(orphan, "entity_type") || metadata_diff_orphan_entity_type(path) entity_type = MapUtils.attr(orphan, :entity_type) || metadata_diff_orphan_entity_type(path)
%{ %{
tab_id: metadata_diff_tab_id(entity_type), tab_id: metadata_diff_tab_id(entity_type),
entity_type: entity_type, entity_type: entity_type,
file_path: path, file_path: path,
slug: Path.basename(path) |> String.trim(), slug: Path.basename(path) |> String.trim(),
id: Map.get(orphan, :id) || Map.get(orphan, "id") id: MapUtils.attr(orphan, :id)
} }
end end
defp metadata_diff_item_label(item, entity_id) do defp metadata_diff_item_label(item, entity_id) do
Map.get(item, :label) || Map.get(item, "label") || Map.get(item, :title) || Map.get(item, "title") || Map.get(item, :slug) || Map.get(item, "slug") || entity_id MapUtils.attr(item, :label) || MapUtils.attr(item, :title) || MapUtils.attr(item, :slug) ||
entity_id
end end
defp metadata_diff_item_meta_label(item, entity_id) do defp metadata_diff_item_meta_label(item, entity_id) do
Map.get(item, :meta_label) || Map.get(item, "meta_label") || entity_id MapUtils.attr(item, :meta_label) || entity_id
end end
defp metadata_diff_item_type_label("post"), do: translated("Post") defp metadata_diff_item_type_label("post"), do: translated("Post")
@@ -547,7 +624,9 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp metadata_diff_item_type_label("categories"), do: translated("Categories") defp metadata_diff_item_type_label("categories"), do: translated("Categories")
defp metadata_diff_item_type_label("category_meta"), do: translated("Categories") defp metadata_diff_item_type_label("category_meta"), do: translated("Categories")
defp metadata_diff_item_type_label("embedding"), do: translated("Embeddings") defp metadata_diff_item_type_label("embedding"), do: translated("Embeddings")
defp metadata_diff_item_type_label(entity_type), do: entity_type |> String.replace("_", " ") |> String.capitalize()
defp metadata_diff_item_type_label(entity_type),
do: entity_type |> String.replace("_", " ") |> String.capitalize()
defp metadata_diff_tab_id("post"), do: "posts" defp metadata_diff_tab_id("post"), do: "posts"
defp metadata_diff_tab_id("post_translation"), do: "posts" defp metadata_diff_tab_id("post_translation"), do: "posts"
@@ -568,7 +647,9 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp metadata_diff_tab_label("templates"), do: translated("Templates") defp metadata_diff_tab_label("templates"), do: translated("Templates")
defp metadata_diff_tab_label("project"), do: translated("Project") defp metadata_diff_tab_label("project"), do: translated("Project")
defp metadata_diff_tab_label("embeddings"), do: translated("Embeddings") defp metadata_diff_tab_label("embeddings"), do: translated("Embeddings")
defp metadata_diff_tab_label(tab_id), do: tab_id |> String.replace("_", " ") |> String.capitalize()
defp metadata_diff_tab_label(tab_id),
do: tab_id |> String.replace("_", " ") |> String.capitalize()
defp metadata_diff_tab_sort_key("posts"), do: 0 defp metadata_diff_tab_sort_key("posts"), do: 0
defp metadata_diff_tab_sort_key("media"), do: 1 defp metadata_diff_tab_sort_key("media"), do: 1
@@ -588,7 +669,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
defp metadata_diff_repairable_tab?(tab_id), do: tab_id in ["posts", "media", "scripts", "templates", "project", "embeddings"] defp metadata_diff_repairable_tab?(tab_id),
do: tab_id in ["posts", "media", "scripts", "templates", "project", "embeddings"]
defp misc_route_action(:site_validation), do: "validate_site" defp misc_route_action(:site_validation), do: "validate_site"
defp misc_route_action(:metadata_diff), do: "metadata_diff" defp misc_route_action(:metadata_diff), do: "metadata_diff"
@@ -601,7 +683,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp format_metadata_diff_value(value), do: to_string(value) defp format_metadata_diff_value(value), do: to_string(value)
defp diff_name(diff) do defp diff_name(diff) do
Map.get(diff, :field) || Map.get(diff, "field") || Map.get(diff, :name) || Map.get(diff, "name") || "value" MapUtils.attr(diff, :field) || MapUtils.attr(diff, :name) || "value"
end end
defp normalize_translation_validation_report(payload) when is_map(payload) do defp normalize_translation_validation_report(payload) when is_map(payload) do

View File

@@ -261,11 +261,11 @@
<%= for pair <- @misc_editor.pairs do %> <%= for pair <- @misc_editor.pairs do %>
<article class="misc-list-item duplicate-pair-row"> <article class="misc-list-item duplicate-pair-row">
<label><input type="checkbox" checked={duplicate_checked?(@misc_editor, pair_id_from_pair(pair))} phx-click="toggle_duplicate_pair" phx-value-pair-id={pair_id_from_pair(pair)} /> <span></span></label> <label><input type="checkbox" checked={duplicate_checked?(@misc_editor, pair_id_from_pair(pair))} phx-click="toggle_duplicate_pair" phx-value-pair-id={pair_id_from_pair(pair)} /> <span></span></label>
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-value-id={Map.get(pair, :post_id_a) || Map.get(pair, "post_id_a")} phx-value-title={Map.get(pair, :title_a) || Map.get(pair, "title_a") }><%= Map.get(pair, :title_a) || Map.get(pair, "title_a") %></button> <button class="linkish" type="button" phx-click="open_duplicate_post" phx-value-id={BDS.MapUtils.attr(pair, :post_id_a)} phx-value-title={BDS.MapUtils.attr(pair, :title_a)}><%= BDS.MapUtils.attr(pair, :title_a) %></button>
<span>→</span> <span>→</span>
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-value-id={Map.get(pair, :post_id_b) || Map.get(pair, "post_id_b")} phx-value-title={Map.get(pair, :title_b) || Map.get(pair, "title_b") }><%= Map.get(pair, :title_b) || Map.get(pair, "title_b") %></button> <button class="linkish" type="button" phx-click="open_duplicate_post" phx-value-id={BDS.MapUtils.attr(pair, :post_id_b)} phx-value-title={BDS.MapUtils.attr(pair, :title_b)}><%= BDS.MapUtils.attr(pair, :title_b) %></button>
<span class="misc-summary-pill"><%= if(Map.get(pair, :exact_match) || Map.get(pair, "exact_match"), do: translated("Exact Match"), else: "#{Float.round((Map.get(pair, :similarity) || Map.get(pair, "similarity") || 0.0) * 100, 1)}%") %></span> <span class="misc-summary-pill"><%= if(BDS.MapUtils.attr(pair, :exact_match), do: translated("Exact Match"), else: "#{Float.round((BDS.MapUtils.attr(pair, :similarity) || 0.0) * 100, 1)}%") %></span>
<button class="secondary" type="button" phx-click="dismiss_duplicate_pair" phx-value-post-id-a={Map.get(pair, :post_id_a) || Map.get(pair, "post_id_a")} phx-value-post-id-b={Map.get(pair, :post_id_b) || Map.get(pair, "post_id_b")}><%= translated("Dismiss") %></button> <button class="secondary" type="button" phx-click="dismiss_duplicate_pair" phx-value-post-id-a={BDS.MapUtils.attr(pair, :post_id_a)} phx-value-post-id-b={BDS.MapUtils.attr(pair, :post_id_b)}><%= translated("Dismiss") %></button>
</article> </article>
<% end %> <% end %>
</div> </div>

View File

@@ -11,6 +11,7 @@ defmodule BDS.Maintenance.Repair do
import BDS.Maintenance.Progress, only: [report_progress: 4] import BDS.Maintenance.Progress, only: [report_progress: 4]
alias BDS.Embeddings alias BDS.Embeddings
alias BDS.MapUtils
alias BDS.Metadata alias BDS.Metadata
def normalize_entity_type(:post), do: :post def normalize_entity_type(:post), do: :post
@@ -33,31 +34,62 @@ defmodule BDS.Maintenance.Repair do
def normalize_repair_direction(_direction), do: :unsupported def normalize_repair_direction(_direction), do: :unsupported
def repair_metadata_diff_item(project_id, direction, item) do def repair_metadata_diff_item(project_id, direction, item) do
entity_type = Map.get(item, :entity_type) || Map.get(item, "entity_type") entity_type = MapUtils.attr(item, :entity_type)
entity_id = Map.get(item, :entity_id) || Map.get(item, "entity_id") entity_id = MapUtils.attr(item, :entity_id)
case {normalize_repair_direction(direction), entity_type} do case {normalize_repair_direction(direction), entity_type} do
{:file_to_db, entity_type} when entity_type in ["project", "categories", "category_meta", "publishing"] -> {:file_to_db, entity_type}
when entity_type in ["project", "categories", "category_meta", "publishing"] ->
Metadata.sync_project_metadata_from_filesystem(project_id) Metadata.sync_project_metadata_from_filesystem(project_id)
{:db_to_file, entity_type} when entity_type in ["project", "categories", "category_meta", "publishing"] -> {:db_to_file, entity_type}
when entity_type in ["project", "categories", "category_meta", "publishing"] ->
Metadata.flush_project_metadata_to_filesystem(project_id) Metadata.flush_project_metadata_to_filesystem(project_id)
{:file_to_db, "post"} -> BDS.Posts.sync_post_from_file(entity_id) {:file_to_db, "post"} ->
{:db_to_file, "post"} -> BDS.Posts.rewrite_published_post(entity_id) BDS.Posts.sync_post_from_file(entity_id)
{:file_to_db, "post_translation"} -> BDS.Posts.sync_post_translation_from_file(entity_id)
{:db_to_file, "post_translation"} -> BDS.Posts.rewrite_published_post_translation(entity_id) {:db_to_file, "post"} ->
{:file_to_db, "media"} -> BDS.Media.sync_media_from_sidecar(entity_id) BDS.Posts.rewrite_published_post(entity_id)
{:db_to_file, "media"} -> BDS.Media.sync_media_sidecar(entity_id)
{:file_to_db, "media_translation"} -> BDS.Media.sync_media_translation_from_sidecar(entity_id) {:file_to_db, "post_translation"} ->
{:db_to_file, "media_translation"} -> BDS.Media.sync_media_translation_sidecar(entity_id) BDS.Posts.sync_post_translation_from_file(entity_id)
{:file_to_db, "script"} -> BDS.Scripts.sync_script_from_file(entity_id)
{:db_to_file, "script"} -> BDS.Scripts.sync_published_script_file(entity_id) {:db_to_file, "post_translation"} ->
{:file_to_db, "template"} -> BDS.Templates.sync_template_from_file(entity_id) BDS.Posts.rewrite_published_post_translation(entity_id)
{:db_to_file, "template"} -> BDS.Templates.sync_published_template_file(entity_id)
{:file_to_db, "embedding"} -> BDS.Embeddings.sync_post(entity_id) {:file_to_db, "media"} ->
{:db_to_file, "embedding"} -> BDS.Embeddings.refresh_snapshot(project_id) BDS.Media.sync_media_from_sidecar(entity_id)
_other -> {:error, :unsupported}
{:db_to_file, "media"} ->
BDS.Media.sync_media_sidecar(entity_id)
{:file_to_db, "media_translation"} ->
BDS.Media.sync_media_translation_from_sidecar(entity_id)
{:db_to_file, "media_translation"} ->
BDS.Media.sync_media_translation_sidecar(entity_id)
{:file_to_db, "script"} ->
BDS.Scripts.sync_script_from_file(entity_id)
{:db_to_file, "script"} ->
BDS.Scripts.sync_published_script_file(entity_id)
{:file_to_db, "template"} ->
BDS.Templates.sync_template_from_file(entity_id)
{:db_to_file, "template"} ->
BDS.Templates.sync_published_template_file(entity_id)
{:file_to_db, "embedding"} ->
BDS.Embeddings.sync_post(entity_id)
{:db_to_file, "embedding"} ->
BDS.Embeddings.refresh_snapshot(project_id)
_other ->
{:error, :unsupported}
end end
end end
@@ -87,7 +119,8 @@ defmodule BDS.Maintenance.Repair do
end end
end end
def repair_embedding_batch(_project_id, _direction, _items, _on_progress, _total), do: :unsupported def repair_embedding_batch(_project_id, _direction, _items, _on_progress, _total),
do: :unsupported
defp build_batch_repair_result(items, total, on_progress, repaired?) do defp build_batch_repair_result(items, total, on_progress, repaired?) do
items items
@@ -106,15 +139,15 @@ defmodule BDS.Maintenance.Repair do
end end
defp metadata_diff_item_entity_type(item) do defp metadata_diff_item_entity_type(item) do
Map.get(item, :entity_type) || Map.get(item, "entity_type") MapUtils.attr(item, :entity_type)
end end
defp metadata_diff_item_entity_id(item) do defp metadata_diff_item_entity_id(item) do
Map.get(item, :entity_id) || Map.get(item, "entity_id") MapUtils.attr(item, :entity_id)
end end
def import_metadata_diff_orphan(project_id, orphan) do def import_metadata_diff_orphan(project_id, orphan) do
file_path = Map.get(orphan, :file_path) || Map.get(orphan, "file_path") file_path = MapUtils.attr(orphan, :file_path)
cond do cond do
is_nil(file_path) -> is_nil(file_path) ->

View File

@@ -13,6 +13,15 @@ defmodule BDS.MapUtils do
end end
end end
@spec attr(attrs(), atom(), term()) :: term()
def attr(attrs, key, default) do
cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key)
Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key))
true -> default
end
end
@spec maybe_put(map(), term(), term()) :: map() @spec maybe_put(map(), term(), term()) :: map()
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)

View File

@@ -173,7 +173,14 @@ defmodule BDS.Metadata do
|> Repo.update!() |> Repo.update!()
persist_setting(project_id, "project", stringify_project_metadata(filesystem_state), now) persist_setting(project_id, "project", stringify_project_metadata(filesystem_state), now)
persist_setting(project_id, "categories", %{"categories" => filesystem_state.categories}, now)
persist_setting(
project_id,
"categories",
%{"categories" => filesystem_state.categories},
now
)
persist_setting( persist_setting(
project_id, project_id,
"category_meta", "category_meta",
@@ -247,10 +254,15 @@ defmodule BDS.Metadata do
read_json(project, "project.json") || read_json(project, "project.json") ||
stringify_project_metadata(default_project_metadata(project)) stringify_project_metadata(default_project_metadata(project))
categories = normalized_categories(read_json(project, "categories.json") || %{"categories" => @default_categories}) categories =
normalized_categories(
read_json(project, "categories.json") || %{"categories" => @default_categories}
)
category_settings = category_settings =
normalized_category_settings(read_json(project, "category-meta.json") || %{"categories" => %{}}) normalized_category_settings(
read_json(project, "category-meta.json") || %{"categories" => %{}}
)
publishing_preferences = read_json(project, "publishing.json") || %{"ssh_mode" => "scp"} publishing_preferences = read_json(project, "publishing.json") || %{"ssh_mode" => "scp"}
@@ -305,14 +317,11 @@ defmodule BDS.Metadata do
defp normalize_category_settings(settings) do defp normalize_category_settings(settings) do
%{ %{
"render_in_lists" => "render_in_lists" => attr(settings, :render_in_lists, true),
Map.get(settings, :render_in_lists, Map.get(settings, "render_in_lists", true)), "show_title" => attr(settings, :show_title, true),
"show_title" => Map.get(settings, :show_title, Map.get(settings, "show_title", true)), "post_template_slug" => attr(settings, :post_template_slug),
"post_template_slug" => "list_template_slug" => attr(settings, :list_template_slug),
Map.get(settings, :post_template_slug, Map.get(settings, "post_template_slug")), "title" => attr(settings, :title)
"list_template_slug" =>
Map.get(settings, :list_template_slug, Map.get(settings, "list_template_slug")),
"title" => Map.get(settings, :title, Map.get(settings, "title"))
} }
end end
@@ -447,7 +456,9 @@ defmodule BDS.Metadata do
|> Map.new() |> Map.new()
end end
defp normalized_categories(%{"categories" => categories}) when is_list(categories), do: categories defp normalized_categories(%{"categories" => categories}) when is_list(categories),
do: categories
defp normalized_categories(categories) when is_list(categories), do: categories defp normalized_categories(categories) when is_list(categories), do: categories
defp normalized_categories(_payload), do: @default_categories defp normalized_categories(_payload), do: @default_categories
@@ -459,13 +470,25 @@ defmodule BDS.Metadata do
{category, {category,
%{ %{
"render_in_lists" => "render_in_lists" =>
Map.get(category_settings, "render_in_lists", Map.get(category_settings, "renderInLists", true)), Map.get(
category_settings,
"render_in_lists",
Map.get(category_settings, "renderInLists", true)
),
"show_title" => "show_title" =>
Map.get(category_settings, "show_title", Map.get(category_settings, "showTitle", true)), Map.get(category_settings, "show_title", Map.get(category_settings, "showTitle", true)),
"post_template_slug" => "post_template_slug" =>
Map.get(category_settings, "post_template_slug", Map.get(category_settings, "postTemplateSlug")), Map.get(
category_settings,
"post_template_slug",
Map.get(category_settings, "postTemplateSlug")
),
"list_template_slug" => "list_template_slug" =>
Map.get(category_settings, "list_template_slug", Map.get(category_settings, "listTemplateSlug")), Map.get(
category_settings,
"list_template_slug",
Map.get(category_settings, "listTemplateSlug")
),
"title" => Map.get(category_settings, "title") "title" => Map.get(category_settings, "title")
} }
|> Enum.reject(fn {_key, value} -> is_nil(value) end) |> Enum.reject(fn {_key, value} -> is_nil(value) end)
@@ -480,10 +503,12 @@ defmodule BDS.Metadata do
"publicUrl" => Map.get(project_metadata, "public_url"), "publicUrl" => Map.get(project_metadata, "public_url"),
"mainLanguage" => Map.get(project_metadata, "main_language"), "mainLanguage" => Map.get(project_metadata, "main_language"),
"defaultAuthor" => Map.get(project_metadata, "default_author"), "defaultAuthor" => Map.get(project_metadata, "default_author"),
"maxPostsPerPage" => Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page), "maxPostsPerPage" =>
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
"blogmarkCategory" => Map.get(project_metadata, "blogmark_category"), "blogmarkCategory" => Map.get(project_metadata, "blogmark_category"),
"picoTheme" => Map.get(project_metadata, "pico_theme"), "picoTheme" => Map.get(project_metadata, "pico_theme"),
"semanticSimilarityEnabled" => Map.get(project_metadata, "semantic_similarity_enabled", false), "semanticSimilarityEnabled" =>
Map.get(project_metadata, "semantic_similarity_enabled", false),
"blogLanguages" => Map.get(project_metadata, "blog_languages", []) "blogLanguages" => Map.get(project_metadata, "blog_languages", [])
} }
|> Enum.reject(fn {_key, value} -> is_nil(value) end) |> Enum.reject(fn {_key, value} -> is_nil(value) end)
@@ -587,7 +612,12 @@ defmodule BDS.Metadata do
defp unwrap_transaction({:ok, result}), do: {:ok, result} defp unwrap_transaction({:ok, result}), do: {:ok, result}
defp unwrap_transaction({:error, reason}), do: {:error, reason} defp unwrap_transaction({:error, reason}), do: {:error, reason}
defp maybe_backfill_embeddings({:ok, _metadata} = result, project_id, previous_state, project_metadata) do defp maybe_backfill_embeddings(
{:ok, _metadata} = result,
project_id,
previous_state,
project_metadata
) do
if previous_state.semantic_similarity_enabled != true and if previous_state.semantic_similarity_enabled != true and
project_metadata.semantic_similarity_enabled == true do project_metadata.semantic_similarity_enabled == true do
{:ok, _indexed_post_ids} = Embeddings.index_unindexed(project_id) {:ok, _indexed_post_ids} = Embeddings.index_unindexed(project_id)
@@ -596,7 +626,8 @@ defmodule BDS.Metadata do
result result
end end
defp maybe_backfill_embeddings(result, _project_id, _previous_state, _project_metadata), do: result defp maybe_backfill_embeddings(result, _project_id, _previous_state, _project_metadata),
do: result
defp attr(attrs, key) do defp attr(attrs, key) do
cond do cond do
@@ -605,4 +636,12 @@ defmodule BDS.Metadata do
true -> nil true -> nil
end end
end end
defp attr(attrs, key, default) do
cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key)
Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key))
true -> default
end
end
end end

View File

@@ -2,6 +2,7 @@ defmodule BDS.Rendering.ListArchive do
@moduledoc false @moduledoc false
alias BDS.I18n alias BDS.I18n
alias BDS.MapUtils
alias BDS.Persistence alias BDS.Persistence
alias BDS.Rendering.LinksAndLanguages alias BDS.Rendering.LinksAndLanguages
alias BDS.Rendering.Metadata, as: RenderMetadata alias BDS.Rendering.Metadata, as: RenderMetadata
@@ -12,18 +13,19 @@ defmodule BDS.Rendering.ListArchive do
metadata = RenderMetadata.project_metadata(project_id) metadata = RenderMetadata.project_metadata(project_id)
template_context = TemplateSelection.template_render_context(project_id) template_context = TemplateSelection.template_render_context(project_id)
language = language = MapUtils.attr(assigns, :language, metadata.main_language || "en")
Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en"))
main_language = metadata.main_language || language main_language = metadata.main_language || language
archive_context = Map.get(assigns, :archive_context, Map.get(assigns, "archive_context", %{})) archive_context = MapUtils.attr(assigns, :archive_context, %{})
canonical_post_paths =
LinksAndLanguages.canonical_post_path_by_slug(project_id, main_language)
canonical_post_paths = LinksAndLanguages.canonical_post_path_by_slug(project_id, main_language)
canonical_media_paths = LinksAndLanguages.canonical_media_path_by_source_path(project_id) canonical_media_paths = LinksAndLanguages.canonical_media_path_by_source_path(project_id)
posts = posts =
normalize_list_posts( normalize_list_posts(
Map.get(assigns, :posts, Map.get(assigns, "posts", [])), MapUtils.attr(assigns, :posts, []),
canonical_post_paths, canonical_post_paths,
canonical_media_paths, canonical_media_paths,
language, language,
@@ -31,7 +33,7 @@ defmodule BDS.Rendering.ListArchive do
) )
pagination = pagination =
normalize_pagination(Map.get(assigns, :pagination, Map.get(assigns, "pagination")), posts) normalize_pagination(MapUtils.attr(assigns, :pagination), posts)
day_blocks = build_day_blocks(posts) day_blocks = build_day_blocks(posts)
min_date = min_date(posts) min_date = min_date(posts)
@@ -44,15 +46,23 @@ defmodule BDS.Rendering.ListArchive do
Map.get( Map.get(
assigns, assigns,
:language_prefix, :language_prefix,
Map.get(assigns, "language_prefix", LinksAndLanguages.language_prefix(language, main_language)) Map.get(
assigns,
"language_prefix",
LinksAndLanguages.language_prefix(language, main_language)
)
), ),
page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title")), page_title: MapUtils.attr(assigns, :page_title),
posts: posts, posts: posts,
pico_stylesheet_href: pico_stylesheet_href:
Map.get( Map.get(
assigns, assigns,
:pico_stylesheet_href, :pico_stylesheet_href,
Map.get(assigns, "pico_stylesheet_href", RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)) Map.get(
assigns,
"pico_stylesheet_href",
RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)
)
), ),
html_theme_attribute: html_theme_attribute:
Map.get( Map.get(
@@ -66,7 +76,8 @@ defmodule BDS.Rendering.ListArchive do
calendar_initial_year: calendar_initial_year_from_posts(posts), calendar_initial_year: calendar_initial_year_from_posts(posts),
calendar_initial_month: calendar_initial_month_from_posts(posts), calendar_initial_month: calendar_initial_month_from_posts(posts),
archive_context: normalized_archive_context, archive_context: normalized_archive_context,
show_archive_range_heading: show_archive_range_heading?(normalized_archive_context, day_blocks), show_archive_range_heading:
show_archive_range_heading?(normalized_archive_context, day_blocks),
min_date: min_date, min_date: min_date,
max_date: max_date, max_date: max_date,
is_list_page: true, is_list_page: true,
@@ -91,25 +102,32 @@ defmodule BDS.Rendering.ListArchive do
def not_found_assigns(project_id, assigns) do def not_found_assigns(project_id, assigns) do
metadata = RenderMetadata.project_metadata(project_id) metadata = RenderMetadata.project_metadata(project_id)
language = language = MapUtils.attr(assigns, :language, metadata.main_language || "en")
Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en"))
main_language = metadata.main_language || language main_language = metadata.main_language || language
%{ %{
page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title", "404")), page_title: MapUtils.attr(assigns, :page_title, "404"),
language: language, language: language,
language_prefix: language_prefix:
Map.get( Map.get(
assigns, assigns,
:language_prefix, :language_prefix,
Map.get(assigns, "language_prefix", LinksAndLanguages.language_prefix(language, main_language)) Map.get(
assigns,
"language_prefix",
LinksAndLanguages.language_prefix(language, main_language)
)
), ),
pico_stylesheet_href: pico_stylesheet_href:
Map.get( Map.get(
assigns, assigns,
:pico_stylesheet_href, :pico_stylesheet_href,
Map.get(assigns, "pico_stylesheet_href", RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)) Map.get(
assigns,
"pico_stylesheet_href",
RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)
)
), ),
html_theme_attribute: html_theme_attribute:
Map.get( Map.get(
@@ -143,20 +161,27 @@ defmodule BDS.Rendering.ListArchive do
} }
end end
defp normalize_list_posts(posts, canonical_post_paths, canonical_media_paths, language, template_context) do defp normalize_list_posts(
posts,
canonical_post_paths,
canonical_media_paths,
language,
template_context
) do
Enum.map(posts, fn post -> Enum.map(posts, fn post ->
post_record = PostRendering.load_post_record(post) post_record = PostRendering.load_post_record(post)
raw_content = raw_content =
Map.get( Map.get(
post, post,
:content, :content,
Map.get(post, "content", Map.get(post, :excerpt, Map.get(post, "excerpt", ""))) MapUtils.attr(post, :excerpt, "")
) )
%{ %{
id: Map.get(post, :id, Map.get(post, "id")), id: MapUtils.attr(post, :id),
slug: Map.get(post, :slug, Map.get(post, "slug")), slug: MapUtils.attr(post, :slug),
title: Map.get(post, :title, Map.get(post, "title")), title: MapUtils.attr(post, :title),
content: content:
PostRendering.render_post_content( PostRendering.render_post_content(
raw_content, raw_content,
@@ -166,29 +191,30 @@ defmodule BDS.Rendering.ListArchive do
template_context template_context
), ),
raw_content: raw_content, raw_content: raw_content,
excerpt: excerpt: MapUtils.attr(post, :excerpt, Map.get(post_record || %{}, :excerpt)),
Map.get(post, :excerpt, Map.get(post, "excerpt", Map.get(post_record || %{}, :excerpt))), author: MapUtils.attr(post, :author, Map.get(post_record || %{}, :author)),
author: Map.get(post, :author, Map.get(post, "author", Map.get(post_record || %{}, :author))),
language: language:
Map.get( Map.get(
post, post,
:language, :language,
Map.get(post, "language", Map.get(post_record || %{}, :language)) Map.get(post_record || %{}, :language)
), ),
published_at: published_at:
Map.get(post, :published_at, Map.get(post, "published_at", Map.get(post_record || %{}, :published_at))), MapUtils.attr(post, :published_at, Map.get(post_record || %{}, :published_at)),
created_at: created_at: MapUtils.attr(post, :created_at, Map.get(post_record || %{}, :created_at)),
Map.get(post, :created_at, Map.get(post, "created_at", Map.get(post_record || %{}, :created_at))), updated_at: MapUtils.attr(post, :updated_at, Map.get(post_record || %{}, :updated_at)),
updated_at: tags: MapUtils.attr(post, :tags, Map.get(post_record || %{}, :tags, [])) || [],
Map.get(post, :updated_at, Map.get(post, "updated_at", Map.get(post_record || %{}, :updated_at))),
tags: Map.get(post, :tags, Map.get(post, "tags", Map.get(post_record || %{}, :tags, []))) || [],
categories: categories:
Map.get(post, :categories, Map.get(post, "categories", Map.get(post_record || %{}, :categories, []))) || [], MapUtils.attr(post, :categories, Map.get(post_record || %{}, :categories, [])) || [],
template_slug: template_slug:
Map.get(post, :template_slug, Map.get(post, "template_slug", Map.get(post_record || %{}, :template_slug))), MapUtils.attr(post, :template_slug, Map.get(post_record || %{}, :template_slug)),
do_not_translate: do_not_translate:
Map.get(post, :do_not_translate, Map.get(post, "do_not_translate", Map.get(post_record || %{}, :do_not_translate, false))), MapUtils.attr(
href: Map.get(post, :href, Map.get(post, "href")), post,
:do_not_translate,
Map.get(post_record || %{}, :do_not_translate, false)
),
href: MapUtils.attr(post, :href),
show_title: true, show_title: true,
linked_media: [], linked_media: [],
outgoing_links: [], outgoing_links: [],
@@ -214,24 +240,20 @@ defmodule BDS.Rendering.ListArchive do
defp normalize_pagination(%{} = pagination, posts) do defp normalize_pagination(%{} = pagination, posts) do
total_items = total_items =
Map.get(pagination, :total_items, Map.get(pagination, "total_items", length(posts))) MapUtils.attr(pagination, :total_items, length(posts))
items_per_page = items_per_page =
Map.get(pagination, :items_per_page, Map.get(pagination, "items_per_page", total_items)) MapUtils.attr(pagination, :items_per_page, total_items)
%{ %{
current_page: Map.get(pagination, :current_page, Map.get(pagination, "current_page", 1)), current_page: MapUtils.attr(pagination, :current_page, 1),
total_pages: Map.get(pagination, :total_pages, Map.get(pagination, "total_pages", 1)), total_pages: MapUtils.attr(pagination, :total_pages, 1),
total_items: total_items, total_items: total_items,
items_per_page: items_per_page, items_per_page: items_per_page,
has_prev_page: has_prev_page: MapUtils.attr(pagination, :has_prev_page, false),
Map.get(pagination, :has_prev_page, Map.get(pagination, "has_prev_page", false)), prev_page_href: MapUtils.attr(pagination, :prev_page_href, ""),
prev_page_href: has_next_page: MapUtils.attr(pagination, :has_next_page, false),
Map.get(pagination, :prev_page_href, Map.get(pagination, "prev_page_href", "")), next_page_href: MapUtils.attr(pagination, :next_page_href, "")
has_next_page:
Map.get(pagination, :has_next_page, Map.get(pagination, "has_next_page", false)),
next_page_href:
Map.get(pagination, :next_page_href, Map.get(pagination, "next_page_href", ""))
} }
end end
@@ -239,11 +261,11 @@ defmodule BDS.Rendering.ListArchive do
defp normalize_archive_context(%{} = archive_context) do defp normalize_archive_context(%{} = archive_context) do
%{ %{
kind: Map.get(archive_context, :kind, Map.get(archive_context, "kind")), kind: MapUtils.attr(archive_context, :kind),
name: Map.get(archive_context, :name, Map.get(archive_context, "name")), name: MapUtils.attr(archive_context, :name),
month: Map.get(archive_context, :month, Map.get(archive_context, "month")), month: MapUtils.attr(archive_context, :month),
year: Map.get(archive_context, :year, Map.get(archive_context, "year")), year: MapUtils.attr(archive_context, :year),
day: Map.get(archive_context, :day, Map.get(archive_context, "day")) day: MapUtils.attr(archive_context, :day)
} }
end end
@@ -251,7 +273,12 @@ defmodule BDS.Rendering.ListArchive do
grouped_blocks = grouped_blocks =
posts posts
|> Enum.filter(&is_integer(Map.get(&1, :created_at))) |> Enum.filter(&is_integer(Map.get(&1, :created_at)))
|> Enum.group_by(&(Map.get(&1, :created_at) |> Persistence.from_unix_ms!() |> DateTime.to_date() |> Date.to_iso8601())) |> Enum.group_by(
&(Map.get(&1, :created_at)
|> Persistence.from_unix_ms!()
|> DateTime.to_date()
|> Date.to_iso8601())
)
|> Enum.sort_by(fn {label, _posts} -> label end) |> Enum.sort_by(fn {label, _posts} -> label end)
grouped_blocks grouped_blocks
@@ -287,9 +314,13 @@ defmodule BDS.Rendering.ListArchive do
defp show_archive_range_heading?(%{kind: "date"}, _day_blocks), do: true defp show_archive_range_heading?(%{kind: "date"}, _day_blocks), do: true
defp show_archive_range_heading?(_archive_context, _day_blocks), do: false defp show_archive_range_heading?(_archive_context, _day_blocks), do: false
defp calendar_initial_year_from_posts([post | _rest]), do: RenderMetadata.calendar_initial_year(post) defp calendar_initial_year_from_posts([post | _rest]),
do: RenderMetadata.calendar_initial_year(post)
defp calendar_initial_year_from_posts([]), do: nil defp calendar_initial_year_from_posts([]), do: nil
defp calendar_initial_month_from_posts([post | _rest]), do: RenderMetadata.calendar_initial_month(post) defp calendar_initial_month_from_posts([post | _rest]),
do: RenderMetadata.calendar_initial_month(post)
defp calendar_initial_month_from_posts([]), do: nil defp calendar_initial_month_from_posts([]), do: nil
end end

View File

@@ -5,6 +5,7 @@ defmodule BDS.Rendering.PostRendering do
alias BDS.Rendering.LinksAndLanguages alias BDS.Rendering.LinksAndLanguages
alias BDS.Rendering.Metadata, as: RenderMetadata alias BDS.Rendering.Metadata, as: RenderMetadata
alias BDS.Rendering.TemplateSelection alias BDS.Rendering.TemplateSelection
alias BDS.MapUtils
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.Translation alias BDS.Posts.Translation
alias BDS.Repo alias BDS.Repo
@@ -13,8 +14,7 @@ defmodule BDS.Rendering.PostRendering do
metadata = RenderMetadata.project_metadata(project_id) metadata = RenderMetadata.project_metadata(project_id)
template_context = TemplateSelection.template_render_context(project_id) template_context = TemplateSelection.template_render_context(project_id)
language = language = MapUtils.attr(assigns, :language, metadata.main_language || "en")
Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en"))
main_language = metadata.main_language || language main_language = metadata.main_language || language
post_record = load_post_record(assigns) post_record = load_post_record(assigns)
@@ -22,12 +22,27 @@ defmodule BDS.Rendering.PostRendering do
post_id = canonical_post_id(post_record, assigns) post_id = canonical_post_id(post_record, assigns)
post_categories = Map.get(post_record || %{}, :categories, []) || [] post_categories = Map.get(post_record || %{}, :categories, []) || []
post_tags = Map.get(post_record || %{}, :tags, []) || [] post_tags = Map.get(post_record || %{}, :tags, []) || []
canonical_post_paths = LinksAndLanguages.canonical_post_path_by_slug(project_id, main_language)
canonical_post_paths =
LinksAndLanguages.canonical_post_path_by_slug(project_id, main_language)
canonical_media_paths = LinksAndLanguages.canonical_media_path_by_source_path(project_id) canonical_media_paths = LinksAndLanguages.canonical_media_path_by_source_path(project_id)
raw_content = Map.get(assigns, :content, Map.get(assigns, "content")) raw_content = MapUtils.attr(assigns, :content)
rendered_content = render_post_content(raw_content, canonical_post_paths, canonical_media_paths, language, template_context)
incoming_links = LinksAndLanguages.link_contexts(project_id, post_id, :incoming, main_language) rendered_content =
outgoing_links = LinksAndLanguages.link_contexts(project_id, post_id, :outgoing, main_language) render_post_content(
raw_content,
canonical_post_paths,
canonical_media_paths,
language,
template_context
)
incoming_links =
LinksAndLanguages.link_contexts(project_id, post_id, :incoming, main_language)
outgoing_links =
LinksAndLanguages.link_contexts(project_id, post_id, :outgoing, main_language)
post_assigns = post_assigns =
assigns assigns
@@ -40,19 +55,27 @@ defmodule BDS.Rendering.PostRendering do
Map.get( Map.get(
assigns, assigns,
:language_prefix, :language_prefix,
Map.get(assigns, "language_prefix", LinksAndLanguages.language_prefix(language, main_language)) Map.get(
assigns,
"language_prefix",
LinksAndLanguages.language_prefix(language, main_language)
)
), ),
page_title: page_title:
Map.get( Map.get(
assigns, assigns,
:page_title, :page_title,
Map.get(assigns, "page_title", Map.get(assigns, :title, Map.get(assigns, "title"))) MapUtils.attr(assigns, :title)
), ),
pico_stylesheet_href: pico_stylesheet_href:
Map.get( Map.get(
assigns, assigns,
:pico_stylesheet_href, :pico_stylesheet_href,
Map.get(assigns, "pico_stylesheet_href", RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)) Map.get(
assigns,
"pico_stylesheet_href",
RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)
)
), ),
html_theme_attribute: html_theme_attribute:
Map.get( Map.get(
@@ -77,7 +100,7 @@ defmodule BDS.Rendering.PostRendering do
end end
def load_post_record(assigns) do def load_post_record(assigns) do
case Map.get(assigns, :id, Map.get(assigns, "id")) do case MapUtils.attr(assigns, :id) do
nil -> nil nil -> nil
post_id -> Repo.get(Post, post_id) || Repo.get(Translation, post_id) post_id -> Repo.get(Post, post_id) || Repo.get(Translation, post_id)
end end
@@ -89,17 +112,33 @@ defmodule BDS.Rendering.PostRendering do
defp canonical_post_id(%Translation{translation_for: post_id}, _assigns), do: post_id defp canonical_post_id(%Translation{translation_for: post_id}, _assigns), do: post_id
defp canonical_post_id(%Post{id: post_id}, _assigns), do: post_id defp canonical_post_id(%Post{id: post_id}, _assigns), do: post_id
defp canonical_post_id(_post_record, assigns), do: Map.get(assigns, :id, Map.get(assigns, "id")) defp canonical_post_id(_post_record, assigns), do: MapUtils.attr(assigns, :id)
defp post_data_json(assigns, post_record) do defp post_data_json(assigns, post_record) do
id = Map.get(assigns, :id, Map.get(assigns, "id")) id = MapUtils.attr(assigns, :id)
if is_binary(id) do if is_binary(id) do
incoming_links = LinksAndLanguages.link_contexts(Map.get(post_record || %{}, :project_id), canonical_post_id(post_record, assigns), :incoming, Map.get(post_record || %{}, :language)) incoming_links =
outgoing_links = LinksAndLanguages.link_contexts(Map.get(post_record || %{}, :project_id), canonical_post_id(post_record, assigns), :outgoing, Map.get(post_record || %{}, :language)) LinksAndLanguages.link_contexts(
Map.get(post_record || %{}, :project_id),
canonical_post_id(post_record, assigns),
:incoming,
Map.get(post_record || %{}, :language)
)
outgoing_links =
LinksAndLanguages.link_contexts(
Map.get(post_record || %{}, :project_id),
canonical_post_id(post_record, assigns),
:outgoing,
Map.get(post_record || %{}, :language)
)
%{ %{
id => post_data_json_value(build_post_context(assigns, post_record, incoming_links, outgoing_links)) id =>
post_data_json_value(
build_post_context(assigns, post_record, incoming_links, outgoing_links)
)
} }
else else
%{} %{}
@@ -124,23 +163,23 @@ defmodule BDS.Rendering.PostRendering do
defp build_post_context(assigns, post_record, incoming_links, outgoing_links) do defp build_post_context(assigns, post_record, incoming_links, outgoing_links) do
%{ %{
id: Map.get(assigns, :id, Map.get(assigns, "id")), id: MapUtils.attr(assigns, :id),
slug: Map.get(assigns, :slug, Map.get(assigns, "slug")), slug: MapUtils.attr(assigns, :slug),
title: Map.get(assigns, :title, Map.get(assigns, "title")), title: MapUtils.attr(assigns, :title),
content: Map.get(assigns, :content, Map.get(assigns, "content")), content: MapUtils.attr(assigns, :content),
raw_content: Map.get(assigns, :raw_content, Map.get(assigns, "raw_content")), raw_content: MapUtils.attr(assigns, :raw_content),
excerpt: excerpt:
Map.get( Map.get(
assigns, assigns,
:excerpt, :excerpt,
Map.get(assigns, "excerpt", Map.get(post_record || %{}, :excerpt)) Map.get(post_record || %{}, :excerpt)
), ),
author: Map.get(post_record || %{}, :author), author: Map.get(post_record || %{}, :author),
language: language:
Map.get( Map.get(
assigns, assigns,
:language, :language,
Map.get(assigns, "language", Map.get(post_record || %{}, :language)) Map.get(post_record || %{}, :language)
), ),
show_title: true, show_title: true,
published_at: Map.get(post_record || %{}, :published_at), published_at: Map.get(post_record || %{}, :published_at),
@@ -152,7 +191,7 @@ defmodule BDS.Rendering.PostRendering do
Map.get( Map.get(
post_record || %{}, post_record || %{},
:template_slug, :template_slug,
Map.get(assigns, :template_slug, Map.get(assigns, "template_slug")) MapUtils.attr(assigns, :template_slug)
), ),
do_not_translate: Map.get(post_record || %{}, :do_not_translate, false), do_not_translate: Map.get(post_record || %{}, :do_not_translate, false),
linked_media: [], linked_media: [],
@@ -161,7 +200,19 @@ defmodule BDS.Rendering.PostRendering do
} }
end end
def render_post_content(content, canonical_post_paths, canonical_media_paths, language, template_context) do def render_post_content(
Filters.render_markdown(content, canonical_post_paths, canonical_media_paths, language, template_context) content,
canonical_post_paths,
canonical_media_paths,
language,
template_context
) do
Filters.render_markdown(
content,
canonical_post_paths,
canonical_media_paths,
language,
template_context
)
end end
end end

View File

@@ -32,13 +32,14 @@ defmodule BDS.UI.Commands do
] ]
def handle_shortcut(state, shortcut) when is_map(shortcut) do def handle_shortcut(state, shortcut) when is_map(shortcut) do
key = shortcut |> Map.get(:key, Map.get(shortcut, "key", "")) |> String.downcase() key = shortcut |> BDS.MapUtils.attr(:key, "") |> String.downcase()
primary =
Map.get(shortcut, :meta, Map.get(shortcut, "meta", false)) or
Map.get(shortcut, :ctrl, Map.get(shortcut, "ctrl", false))
shift = Map.get(shortcut, :shift, Map.get(shortcut, "shift", false)) primary =
alt = Map.get(shortcut, :alt, Map.get(shortcut, "alt", false)) BDS.MapUtils.attr(shortcut, :meta, false) or
BDS.MapUtils.attr(shortcut, :ctrl, false)
shift = BDS.MapUtils.attr(shortcut, :shift, false)
alt = BDS.MapUtils.attr(shortcut, :alt, false)
case Enum.find(@menu_shortcuts, &shortcut_match?(&1, key, primary, shift, alt)) do case Enum.find(@menu_shortcuts, &shortcut_match?(&1, key, primary, shift, alt)) do
%{id: command_id} -> MenuBar.execute(state, command_id) %{id: command_id} -> MenuBar.execute(state, command_id)

View File

@@ -27,7 +27,13 @@ defmodule BDS.UI.Sidebar do
"templates" => view(project_id, "templates"), "templates" => view(project_id, "templates"),
"tags" => view(project_id, "tags"), "tags" => view(project_id, "tags"),
"chat" => view(project_id, "chat"), "chat" => view(project_id, "chat"),
"import" => entity_list_view("Import", "Import definitions", "import", list_import_definitions(project_id)), "import" =>
entity_list_view(
"Import",
"Import definitions",
"import",
list_import_definitions(project_id)
),
"git" => git_view(), "git" => git_view(),
"settings" => settings_nav_view() "settings" => settings_nav_view()
} }
@@ -41,17 +47,43 @@ defmodule BDS.UI.Sidebar do
normalized_view = normalize_view_id(view_id) normalized_view = normalize_view_id(view_id)
case normalized_view do case normalized_view do
"posts" -> posts_view(project_id, params, false) "posts" ->
"pages" -> posts_view(project_id, params, true) posts_view(project_id, params, false)
"media" -> media_view(project_id, params)
"scripts" -> entity_list_view("Scripts", "Automation helpers", "scripts", list_scripts(project_id)) "pages" ->
"templates" -> entity_list_view("Templates", "Site rendering", "templates", list_templates(project_id)) posts_view(project_id, params, true)
"tags" -> tags_nav_view(list_tags(project_id))
"chat" -> entity_list_view("Chat", "AI conversations", "chat", list_conversations()) "media" ->
"import" -> entity_list_view("Import", "Import definitions", "import", list_import_definitions(project_id)) media_view(project_id, params)
"git" -> git_view()
"settings" -> settings_nav_view() "scripts" ->
_other -> empty_view(normalized_view) entity_list_view("Scripts", "Automation helpers", "scripts", list_scripts(project_id))
"templates" ->
entity_list_view("Templates", "Site rendering", "templates", list_templates(project_id))
"tags" ->
tags_nav_view(list_tags(project_id))
"chat" ->
entity_list_view("Chat", "AI conversations", "chat", list_conversations())
"import" ->
entity_list_view(
"Import",
"Import definitions",
"import",
list_import_definitions(project_id)
)
"git" ->
git_view()
"settings" ->
settings_nav_view()
_other ->
empty_view(normalized_view)
end end
end end
@@ -74,13 +106,18 @@ defmodule BDS.UI.Sidebar do
defp empty_view("pages"), do: posts_view_data([], [], %{}, true, empty_filter_params(), %{}) defp empty_view("pages"), do: posts_view_data([], [], %{}, true, empty_filter_params(), %{})
defp empty_view("media"), do: media_view_data([], [], empty_filter_params(), %{}) defp empty_view("media"), do: media_view_data([], [], empty_filter_params(), %{})
defp empty_view("scripts"), do: entity_list_view("Scripts", "Automation helpers", "scripts", []) defp empty_view("scripts"), do: entity_list_view("Scripts", "Automation helpers", "scripts", [])
defp empty_view("templates"), do: entity_list_view("Templates", "Site rendering", "templates", [])
defp empty_view("templates"),
do: entity_list_view("Templates", "Site rendering", "templates", [])
defp empty_view("tags"), do: tags_nav_view([]) defp empty_view("tags"), do: tags_nav_view([])
defp empty_view("chat"), do: entity_list_view("Chat", "AI conversations", "chat", []) defp empty_view("chat"), do: entity_list_view("Chat", "AI conversations", "chat", [])
defp empty_view("import"), do: entity_list_view("Import", "Import definitions", "import", []) defp empty_view("import"), do: entity_list_view("Import", "Import definitions", "import", [])
defp empty_view("git"), do: git_view() defp empty_view("git"), do: git_view()
defp empty_view("settings"), do: settings_nav_view() defp empty_view("settings"), do: settings_nav_view()
defp empty_view(_other), do: %{title: "", subtitle: "", layout: "entity_list", items: [], empty_message: "No items"}
defp empty_view(_other),
do: %{title: "", subtitle: "", layout: "entity_list", items: [], empty_message: "No items"}
defp posts_view(project_id, params, pages?) do defp posts_view(project_id, params, pages?) do
posts = list_posts(project_id) posts = list_posts(project_id)
@@ -93,7 +130,14 @@ defmodule BDS.UI.Sidebar do
posts_view_data(base_posts, filtered_posts, translation_counts, pages?, filters, tag_colors) posts_view_data(base_posts, filtered_posts, translation_counts, pages?, filters, tag_colors)
end end
defp posts_view_data(base_posts, filtered_posts, translation_counts, pages?, filters, tag_colors) do defp posts_view_data(
base_posts,
filtered_posts,
translation_counts,
pages?,
filters,
tag_colors
) do
limited_posts = Enum.take(filtered_posts, filters.display_limit) limited_posts = Enum.take(filtered_posts, filters.display_limit)
grouped_posts = group_posts(limited_posts) grouped_posts = group_posts(limited_posts)
available_tags = available_tags(base_posts, & &1.tags) available_tags = available_tags(base_posts, & &1.tags)
@@ -101,12 +145,14 @@ defmodule BDS.UI.Sidebar do
%{ %{
title: if(pages?, do: "Pages", else: "Posts"), title: if(pages?, do: "Pages", else: "Posts"),
subtitle: if(pages?, do: "Standalone pages", else: "Drafts, published entries, and archive history"), subtitle:
if(pages?, do: "Standalone pages", else: "Drafts, published entries, and archive history"),
layout: "post_list", layout: "post_list",
empty_message: if(pages?, do: "sidebar.noPagesYet", else: "sidebar.noPostsYet"), empty_message: if(pages?, do: "sidebar.noPagesYet", else: "sidebar.noPostsYet"),
filters: %{ filters: %{
enabled: true, enabled: true,
search_placeholder: if(pages?, do: "sidebar.searchPagesPlaceholder", else: "sidebar.searchPostsPlaceholder"), search_placeholder:
if(pages?, do: "sidebar.searchPagesPlaceholder", else: "sidebar.searchPostsPlaceholder"),
toggle_filters_label: "sidebar.toggleFilters", toggle_filters_label: "sidebar.toggleFilters",
archive_label: "render.archive", archive_label: "render.archive",
tags_label: "sidebar.tags", tags_label: "sidebar.tags",
@@ -137,8 +183,20 @@ defmodule BDS.UI.Sidebar do
}, },
sections: [ sections: [
build_post_section("Drafts", :draft, grouped_posts.draft, translation_counts, false), build_post_section("Drafts", :draft, grouped_posts.draft, translation_counts, false),
build_post_section("Published", :published, grouped_posts.published, translation_counts, true), build_post_section(
build_post_section("Archived", :archived, grouped_posts.archived, translation_counts, false) "Published",
:published,
grouped_posts.published,
translation_counts,
true
),
build_post_section(
"Archived",
:archived,
grouped_posts.archived,
translation_counts,
false
)
] ]
} }
end end
@@ -246,7 +304,13 @@ defmodule BDS.UI.Sidebar do
layout: "entity_list", layout: "entity_list",
empty_message: "No items", empty_message: "No items",
items: [ items: [
%{id: "git-working-tree", title: "Working tree", meta: "Working tree and history", route: "git_diff", updated_at: nil} %{
id: "git-working-tree",
title: "Working tree",
meta: "Working tree and history",
route: "git_diff",
updated_at: nil
}
] ]
} }
end end
@@ -285,7 +349,8 @@ defmodule BDS.UI.Sidebar do
tags: post.tags || [], tags: post.tags || [],
status: Atom.to_string(post.status), status: Atom.to_string(post.status),
language_count: 1 + Map.get(translation_counts, post.id, 0), language_count: 1 + Map.get(translation_counts, post.id, 0),
meta_timestamp: if(published_meta?, do: post.published_at || post.updated_at, else: post.updated_at), meta_timestamp:
if(published_meta?, do: post.published_at || post.updated_at, else: post.updated_at),
route: "post", route: "post",
search_blob: post_search_blob(post) search_blob: post_search_blob(post)
} }
@@ -377,7 +442,11 @@ defmodule BDS.UI.Sidebar do
Repo.all( Repo.all(
from conversation in ChatConversation, from conversation in ChatConversation,
order_by: [desc: conversation.updated_at, desc: conversation.created_at], order_by: [desc: conversation.updated_at, desc: conversation.created_at],
select: %{id: conversation.id, title: conversation.title, updated_at: conversation.updated_at} select: %{
id: conversation.id,
title: conversation.title,
updated_at: conversation.updated_at
}
) )
end end
@@ -402,15 +471,15 @@ defmodule BDS.UI.Sidebar do
defp normalize_filter_params(params) when is_map(params) do defp normalize_filter_params(params) when is_map(params) do
%{ %{
search: normalize_string(Map.get(params, "search") || Map.get(params, :search)), search: normalize_string(BDS.MapUtils.attr(params, :search)),
year: normalize_integer(Map.get(params, "year") || Map.get(params, :year)), year: normalize_integer(BDS.MapUtils.attr(params, :year)),
month: normalize_integer(Map.get(params, "month") || Map.get(params, :month)), month: normalize_integer(BDS.MapUtils.attr(params, :month)),
tags: normalize_string_list(Map.get(params, "tags") || Map.get(params, :tags)), tags: normalize_string_list(BDS.MapUtils.attr(params, :tags)),
categories: normalize_string_list(Map.get(params, "categories") || Map.get(params, :categories)), categories: normalize_string_list(BDS.MapUtils.attr(params, :categories)),
display_limit: display_limit:
max( max(
@default_page_size, @default_page_size,
normalize_integer(Map.get(params, "display_limit") || Map.get(params, :display_limit)) || @default_page_size normalize_integer(BDS.MapUtils.attr(params, :display_limit)) || @default_page_size
) )
} }
end end
@@ -418,11 +487,19 @@ defmodule BDS.UI.Sidebar do
defp normalize_filter_params(_params), do: empty_filter_params() defp normalize_filter_params(_params), do: empty_filter_params()
defp empty_filter_params do defp empty_filter_params do
%{search: nil, year: nil, month: nil, tags: [], categories: [], display_limit: @default_page_size} %{
search: nil,
year: nil,
month: nil,
tags: [],
categories: [],
display_limit: @default_page_size
}
end end
defp filter_active?(filters) do defp filter_active?(filters) do
present?(filters.search) or not is_nil(filters.year) or filters.tags != [] or filters.categories != [] present?(filters.search) or not is_nil(filters.year) or filters.tags != [] or
filters.categories != []
end end
defp apply_post_filters(posts, filters) do defp apply_post_filters(posts, filters) do
@@ -497,7 +574,9 @@ defmodule BDS.UI.Sidebar do
posts posts
|> Enum.flat_map(&filtered_categories(&1.categories || [])) |> Enum.flat_map(&filtered_categories(&1.categories || []))
|> then(fn categories -> |> then(fn categories ->
if pages?, do: Enum.reject(categories, &(normalize_term(&1) == @page_category)), else: categories if pages?,
do: Enum.reject(categories, &(normalize_term(&1) == @page_category)),
else: categories
end) end)
|> Enum.map(&to_string/1) |> Enum.map(&to_string/1)
|> Enum.reject(&(&1 == "")) |> Enum.reject(&(&1 == ""))
@@ -522,7 +601,13 @@ defmodule BDS.UI.Sidebar do
defp post_filter_timestamp(post), do: post.published_at || post.updated_at defp post_filter_timestamp(post), do: post.published_at || post.updated_at
defp post_search_blob(post) do defp post_search_blob(post) do
[post.title, post.slug, post.excerpt, Enum.join(post.tags || [], " "), Enum.join(post.categories || [], " ")] [
post.title,
post.slug,
post.excerpt,
Enum.join(post.tags || [], " "),
Enum.join(post.categories || [], " ")
]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
|> Enum.join(" ") |> Enum.join(" ")
end end

View File

@@ -10,6 +10,12 @@ defmodule BDS.MapUtilsTest do
assert MapUtils.attr(%{"title" => "fallback", title: nil}, :title) == nil assert MapUtils.attr(%{"title" => "fallback", title: nil}, :title) == nil
assert MapUtils.attr(%{}, :title) == nil assert MapUtils.attr(%{}, :title) == nil
end end
test "reads with a default while preserving explicit nil and false" do
assert MapUtils.attr(%{}, :published, true) == true
assert MapUtils.attr(%{"published" => false}, :published, true) == false
assert MapUtils.attr(%{"published" => nil}, :published, true) == nil
end
end end
describe "maybe_put/3" do describe "maybe_put/3" do
@@ -28,4 +34,47 @@ defmodule BDS.MapUtilsTest do
assert MapUtils.blank_to_nil(42) == 42 assert MapUtils.blank_to_nil(42) == 42
end end
end end
describe "atom/string key duality" do
test "shared attr helper is used for same-name atom and string reads" do
root = File.cwd!()
offenders =
[Path.join(root, "lib/**/*.ex"), Path.join(root, "lib/**/*.heex")]
|> Enum.flat_map(&Path.wildcard/1)
|> Enum.flat_map(fn path ->
path
|> File.stream!()
|> Stream.with_index(1)
|> Enum.flat_map(fn {line, line_number} ->
if same_name_dual_key_read?(line) do
["#{Path.relative_to(path, root)}:#{line_number}:#{String.trim(line)}"]
else
[]
end
end)
end)
assert offenders == []
end
end
defp same_name_dual_key_read?(line) do
Regex.match?(
~r/Map\.get\((\w+),\s*:([a-zA-Z_][a-zA-Z0-9_?!]*)\).{0,120}Map\.get\(\1,\s*"\2"\)/,
line
) or
Regex.match?(
~r/Map\.get\((\w+),\s*:([a-zA-Z_][a-zA-Z0-9_?!]*),\s*Map\.get\(\1,\s*"\2"/,
line
) or
Regex.match?(
~r/Map\.get\((\w+),\s*"([a-zA-Z_][a-zA-Z0-9_?!]*)"\).{0,120}Map\.get\(\1,\s*:\2\)/,
line
) or
Regex.match?(
~r/Map\.get\((\w+),\s*"([a-zA-Z_][a-zA-Z0-9_?!]*)",\s*Map\.get\(\1,\s*:\2/,
line
)
end
end end