diff --git a/CODESMELL.md b/CODESMELL.md index 72928ef..1633c40 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -121,11 +121,9 @@ _None._ All modules previously on the queue have been split; refresh the queue i ## 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. - -**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. +**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. --- @@ -161,6 +159,8 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search` ### 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. ### 2026-05-10 diff --git a/lib/bds/ai.ex b/lib/bds/ai.ex index 289694a..2eb9a3c 100644 --- a/lib/bds/ai.ex +++ b/lib/bds/ai.ex @@ -6,6 +6,7 @@ defmodule BDS.AI do alias BDS.AI.OneShot alias BDS.AI.Runtime alias BDS.AI.SecretBackend + alias BDS.MapUtils import BDS.AI.SettingsStore, only: [ @@ -21,20 +22,26 @@ defmodule BDS.AI do @type endpoint_kind :: atom() @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." @type endpoint_attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} @spec put_endpoint(endpoint_kind(), endpoint_attrs(), keyword()) :: {: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) kind_key = Atom.to_string(kind) - url = Map.get(attrs, :url) || Map.get(attrs, "url") - model = Map.get(attrs, :model) || Map.get(attrs, "model") - api_key = Map.get(attrs, :api_key) || Map.get(attrs, "api_key") + url = MapUtils.attr(attrs, :url) + model = MapUtils.attr(attrs, :model) + api_key = MapUtils.attr(attrs, :api_key) with :ok <- put_setting("ai.#{kind_key}.url", url), :ok <- put_setting("ai.#{kind_key}.model", model), @@ -103,7 +110,8 @@ defmodule BDS.AI do 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 case Map.fetch(Runtime.model_preference_keys(), key) do {:ok, setting_key} -> put_setting(setting_key, model) @@ -111,7 +119,8 @@ defmodule BDS.AI do 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 case Map.fetch(Runtime.model_preference_keys(), key) do {: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()} 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 @spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()} 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 @spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()} diff --git a/lib/bds/ai/catalog.ex b/lib/bds/ai/catalog.ex index 0d25aed..6f7051e 100644 --- a/lib/bds/ai/catalog.ex +++ b/lib/bds/ai/catalog.ex @@ -2,6 +2,7 @@ defmodule BDS.AI.Catalog do @moduledoc false import Ecto.Query + import BDS.AI.SettingsStore, only: [ get_setting: 1, @@ -21,7 +22,13 @@ defmodule BDS.AI.Catalog do @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 - 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) end @@ -103,8 +110,8 @@ defmodule BDS.AI.Catalog do @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 capabilities = %{ - supports_attachment: truthy?(Map.get(attrs, :supports_attachment) || Map.get(attrs, "supports_attachment")), - supports_tool_calls: truthy?(Map.get(attrs, :supports_tool_calls) || Map.get(attrs, "supports_tool_calls")) + supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)), + supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls)) } put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities)) @@ -154,7 +161,10 @@ defmodule BDS.AI.Catalog do } 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 overrides = decode_model_capabilities_override(model_id) @@ -162,7 +172,7 @@ defmodule BDS.AI.Catalog do case get_catalog_model(model_id) do {: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 } @@ -257,8 +267,19 @@ defmodule BDS.AI.Catalog do |> Model.changeset(model_attrs) |> Repo.insert!() - insert_modalities(provider_id, model_id, Map.get(model_data, "input_modalities", []), :input) - insert_modalities(provider_id, model_id, Map.get(model_data, "output_modalities", []), :output) + insert_modalities( + 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 end) diff --git a/lib/bds/ai/chat.ex b/lib/bds/ai/chat.ex index f2b2009..a9080a4 100644 --- a/lib/bds/ai/chat.ex +++ b/lib/bds/ai/chat.ex @@ -13,6 +13,7 @@ defmodule BDS.AI.Chat do alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.Runtime alias BDS.AI.SecretBackend + alias BDS.MapUtils import BDS.AI.SettingsStore, only: [get_setting: 1] alias BDS.Media.Media alias BDS.Persistence @@ -28,15 +29,15 @@ defmodule BDS.AI.Chat do @spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()} def start_chat(attrs \\ %{}) when is_map(attrs) do now = Persistence.now_ms() - model = Map.get(attrs, :model) || Map.get(attrs, "model") - title = Map.get(attrs, :title) || Map.get(attrs, "title") || generated_chat_title(model) + model = MapUtils.attr(attrs, :model) + title = MapUtils.attr(attrs, :title) || generated_chat_title(model) %ChatConversation{} |> ChatConversation.changeset(%{ id: Ecto.UUID.generate(), title: title, 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, updated_at: now }) @@ -120,12 +121,13 @@ defmodule BDS.AI.Chat do def send_chat_message(conversation_id, content, opts \\ []) when is_binary(conversation_id) and is_binary(content) and is_list(opts) do with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id), - {:ok, user_message} <- persist_chat_message(%{ - conversation_id: conversation.id, - role: :user, - content: content, - created_at: Persistence.now_ms() - }) do + {:ok, user_message} <- + persist_chat_message(%{ + conversation_id: conversation.id, + role: :user, + content: content, + created_at: Persistence.now_ms() + }) do task = Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> receive do @@ -153,7 +155,9 @@ defmodule BDS.AI.Chat do @spec cancel_chat(String.t()) :: :ok def cancel_chat(conversation_id) when is_binary(conversation_id) do case InFlight.lookup(conversation_id) do - nil -> :ok + nil -> + :ok + pid -> _ = Task.Supervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid) :ok @@ -162,7 +166,11 @@ defmodule BDS.AI.Chat do @doc false 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() |> Enum.reject(&blank?/1) |> MapSet.new() @@ -267,9 +275,14 @@ defmodule BDS.AI.Chat do normalized_url = String.downcase(url) cond do - String.contains?(normalized_url, "11434") or String.contains?(normalized_url, "ollama") -> "ollama" - String.contains?(normalized_url, "1234") or String.contains?(normalized_url, "lmstudio") -> "lmstudio" - true -> "generic-openai" + String.contains?(normalized_url, "11434") or String.contains?(normalized_url, "ollama") -> + "ollama" + + String.contains?(normalized_url, "1234") or String.contains?(normalized_url, "lmstudio") -> + "lmstudio" + + true -> + "generic-openai" end end @@ -303,23 +316,58 @@ defmodule BDS.AI.Chat do :ok <- Runtime.validate_target(:chat, model, mode), messages <- load_chat_messages(conversation.id), 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} 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}} 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) - 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 <- touch_conversation(conversation.id) do if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do - notify_chat_event(opts, {:chat_streaming_content, conversation.id, Map.get(response, :content)}) + notify_chat_event( + opts, + {:chat_streaming_content, conversation.id, Map.get(response, :content)} + ) end tool_calls = decode_tool_calls(Map.get(response, :tool_calls)) @@ -330,7 +378,8 @@ defmodule BDS.AI.Chat do cond do 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), {:ok, reply} <- chat_round( @@ -420,8 +469,13 @@ defmodule BDS.AI.Chat do defp message_for_runtime(%ChatMessage{} = message) do base = %{"role" => Atom.to_string(message.role)} - 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 + 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 nil -> base @@ -438,7 +492,9 @@ defmodule BDS.AI.Chat do [system | remainder] = messages {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) 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(project_id) do - post_count = Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id) - media_count = Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id) + post_count = + Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id) + + media_count = + Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id) + tag_count = count_distinct_string_list(Post, :tags, project_id) category_count = count_distinct_string_list(Post, :categories, project_id) @@ -528,9 +588,14 @@ defmodule BDS.AI.Chat do 10 -> {:error, :cancelled} end - :shutdown -> {:error, :cancelled} - {:shutdown, _detail} -> {:error, :cancelled} - _other -> {:error, :cancelled} + :shutdown -> + {:error, :cancelled} + + {:shutdown, _detail} -> + {:error, :cancelled} + + _other -> + {:error, :cancelled} end end end @@ -556,14 +621,22 @@ defmodule BDS.AI.Chat do end 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 model_context_window(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 - _other -> @default_context_window + {:ok, model} when is_integer(model.context_window) and model.context_window > 0 -> + model.context_window + + _other -> + @default_context_window end end diff --git a/lib/bds/ai/one_shot.ex b/lib/bds/ai/one_shot.ex index 835848e..8867e4c 100644 --- a/lib/bds/ai/one_shot.ex +++ b/lib/bds/ai/one_shot.ex @@ -5,6 +5,7 @@ defmodule BDS.AI.OneShot do alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.Runtime alias BDS.Media.Media + alias BDS.MapUtils alias BDS.Posts.Post alias BDS.Repo @@ -45,10 +46,10 @@ defmodule BDS.AI.OneShot do def analyze_import_taxonomy(import_terms, existing_terms, opts \\ []) when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do payload = %{ - import_categories: normalize_string_list(Map.get(import_terms, :categories) || Map.get(import_terms, "categories")), - import_tags: normalize_string_list(Map.get(import_terms, :tags) || Map.get(import_terms, "tags")), - existing_categories: normalize_string_list(Map.get(existing_terms, :categories) || Map.get(existing_terms, "categories")), - existing_tags: normalize_string_list(Map.get(existing_terms, :tags) || Map.get(existing_terms, "tags")) + import_categories: normalize_string_list(MapUtils.attr(import_terms, :categories)), + import_tags: normalize_string_list(MapUtils.attr(import_terms, :tags)), + existing_categories: normalize_string_list(MapUtils.attr(existing_terms, :categories)), + existing_tags: normalize_string_list(MapUtils.attr(existing_terms, :tags)) } run_one_shot( @@ -96,7 +97,8 @@ defmodule BDS.AI.OneShot do 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 \\ []) when is_binary(target_language) and is_list(opts) do with {:ok, post} <- normalize_post_input(post_input) do @@ -138,7 +140,8 @@ defmodule BDS.AI.OneShot do 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 \\ []) when is_binary(target_language) and is_list(opts) 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), :ok <- Runtime.validate_target(operation, model, mode), 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), usage <- Chat.normalize_usage(response.usage), {:ok, result} <- formatter.(json, usage) do @@ -252,7 +256,10 @@ defmodule BDS.AI.OneShot 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}} ] end @@ -286,9 +293,9 @@ defmodule BDS.AI.OneShot do defp normalize_post_input(attrs) when is_map(attrs) do {:ok, %{ - title: Map.get(attrs, :title) || Map.get(attrs, "title") || "", - excerpt: Map.get(attrs, :excerpt) || Map.get(attrs, "excerpt") || "", - content: Map.get(attrs, :content) || Map.get(attrs, "content") || "" + title: MapUtils.attr(attrs, :title) || "", + excerpt: MapUtils.attr(attrs, :excerpt) || "", + content: MapUtils.attr(attrs, :content) || "" }} end @@ -313,11 +320,11 @@ defmodule BDS.AI.OneShot do defp normalize_media_input(attrs) when is_map(attrs) do {:ok, %{ - mime_type: Map.get(attrs, :mime_type) || Map.get(attrs, "mime_type"), - title: Map.get(attrs, :title) || Map.get(attrs, "title") || "", - alt: Map.get(attrs, :alt) || Map.get(attrs, "alt") || "", - caption: Map.get(attrs, :caption) || Map.get(attrs, "caption") || "", - image_url: Map.get(attrs, :image_url) || Map.get(attrs, "image_url") + mime_type: MapUtils.attr(attrs, :mime_type), + title: MapUtils.attr(attrs, :title) || "", + alt: MapUtils.attr(attrs, :alt) || "", + caption: MapUtils.attr(attrs, :caption) || "", + image_url: MapUtils.attr(attrs, :image_url) }} end @@ -336,7 +343,8 @@ defmodule BDS.AI.OneShot do |> Enum.uniq() 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) existing_lookup = canonical_term_lookup(existing_terms) diff --git a/lib/bds/desktop/shell_commands.ex b/lib/bds/desktop/shell_commands.ex index b616796..ae213af 100644 --- a/lib/bds/desktop/shell_commands.ex +++ b/lib/bds/desktop/shell_commands.ex @@ -80,18 +80,26 @@ defmodule BDS.Desktop.ShellCommands do attrs = %{group_id: group_id, group_name: "Search"} {:ok, posts_task} = - Tasks.submit_task("Reindex Search Text", fn report -> - :ok = Search.reindex_posts(project.id, on_progress: report) - report.(1.0, "Post search text reindexed") - %{project_id: project.id, entity: "posts"} - end, attrs) + Tasks.submit_task( + "Reindex Search Text", + fn report -> + :ok = Search.reindex_posts(project.id, on_progress: report) + report.(1.0, "Post search text reindexed") + %{project_id: project.id, entity: "posts"} + end, + attrs + ) {:ok, _media_task} = - Tasks.submit_task("Reindex Media Search Text", fn report -> - :ok = Search.reindex_media(project.id, on_progress: report) - report.(1.0, "Media search text reindexed") - %{project_id: project.id, entity: "media"} - end, attrs) + Tasks.submit_task( + "Reindex Media Search Text", + fn report -> + :ok = Search.reindex_media(project.id, on_progress: report) + report.(1.0, "Media search text reindexed") + %{project_id: project.id, entity: "media"} + end, + attrs + ) {:ok, %{ @@ -107,43 +115,86 @@ defmodule BDS.Desktop.ShellCommands do end defp dispatch("rebuild_embedding_index", project, _params) do - queue_task(project, "rebuild_embedding_index", "Rebuild Embedding Index", "Embeddings", fn report -> - {:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report) - report.(1.0, "Embedding index rebuilt") - %{project_id: project.id, rebuilt_post_ids: rebuilt_post_ids, rebuilt_count: length(rebuilt_post_ids)} - end) + queue_task( + project, + "rebuild_embedding_index", + "Rebuild Embedding Index", + "Embeddings", + fn report -> + {:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report) + report.(1.0, "Embedding index rebuilt") + + %{ + project_id: project.id, + rebuilt_post_ids: rebuilt_post_ids, + rebuilt_count: length(rebuilt_post_ids) + } + end + ) end defp dispatch("rebuild_posts_from_files", project, _params) do - queue_task(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") - %{project_id: project.id, counts: %{posts: length(posts)}} - end) + queue_task( + 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") + %{project_id: project.id, counts: %{posts: length(posts)}} + end + ) end defp dispatch("rebuild_media_from_files", project, _params) do - queue_task(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") - %{project_id: project.id, counts: %{media: length(media)}} - end) + queue_task( + 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") + %{project_id: project.id, counts: %{media: length(media)}} + end + ) end defp dispatch("rebuild_scripts_from_files", project, _params) do - queue_task(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") - %{project_id: project.id, counts: %{scripts: length(scripts)}} - end) + queue_task( + 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") + %{project_id: project.id, counts: %{scripts: length(scripts)}} + end + ) end defp dispatch("rebuild_templates_from_files", project, _params) do - queue_task(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") - %{project_id: project.id, counts: %{templates: length(templates)}} - end) + queue_task( + 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") + %{project_id: project.id, counts: %{templates: length(templates)}} + end + ) end defp dispatch("rebuild_post_links", project, _params) do @@ -155,11 +206,17 @@ defmodule BDS.Desktop.ShellCommands do end defp dispatch("regenerate_missing_thumbnails", project, _params) do - queue_task(project, "regenerate_missing_thumbnails", "Regenerate Missing Thumbnails", "Maintenance", fn report -> - result = BDS.Media.regenerate_missing_thumbnails(project.id, on_progress: report) - report.(1.0, "Missing thumbnails regenerated") - Map.put(result, :project_id, project.id) - end) + queue_task( + project, + "regenerate_missing_thumbnails", + "Regenerate Missing Thumbnails", + "Maintenance", + fn report -> + result = BDS.Media.regenerate_missing_thumbnails(project.id, on_progress: report) + report.(1.0, "Missing thumbnails regenerated") + Map.put(result, :project_id, project.id) + end + ) end defp dispatch("rebuild_database", project, _params) do @@ -192,15 +249,24 @@ defmodule BDS.Desktop.ShellCommands do defp dispatch("generate_sitemap", project, _params) do 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") - %{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 defp dispatch("validate_site", project, _params) do 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) end) end @@ -214,58 +280,76 @@ defmodule BDS.Desktop.ShellCommands do end defp dispatch("repair_metadata_diff", project, params) do - items = normalize_metadata_diff_items(Map.get(params, "items", Map.get(params, :items, []))) - direction = Map.get(params, "direction", Map.get(params, :direction)) + items = normalize_metadata_diff_items(BDS.MapUtils.attr(params, :items, [])) + direction = BDS.MapUtils.attr(params, :direction) if items == [] do {:error, %{action: "repair_metadata_diff", message: "No metadata diff items selected"}} else - queue_task(project, "repair_metadata_diff", "Repair Metadata Diff", "Maintenance", fn report -> - {:ok, _repair} = - Maintenance.repair_metadata_diff(project.id, direction, items, - on_progress: scaled_progress_reporter(report, 0.0, 0.8) - ) + queue_task( + project, + "repair_metadata_diff", + "Repair Metadata Diff", + "Maintenance", + fn report -> + {:ok, _repair} = + Maintenance.repair_metadata_diff(project.id, direction, items, + on_progress: scaled_progress_reporter(report, 0.0, 0.8) + ) - {:ok, metadata_diff} = - Maintenance.metadata_diff(project.id, - on_progress: scaled_progress_reporter(report, 0.8, 0.98) - ) + {:ok, metadata_diff} = + Maintenance.metadata_diff(project.id, + on_progress: scaled_progress_reporter(report, 0.8, 0.98) + ) - report.(1.0, "Metadata diff repair complete") - metadata_diff_result(project.id, metadata_diff) - end) + report.(1.0, "Metadata diff repair complete") + metadata_diff_result(project.id, metadata_diff) + end + ) end end 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 {:error, %{action: "import_metadata_diff_orphans", message: "No orphan files selected"}} else - queue_task(project, "import_metadata_diff_orphans", "Import Metadata Diff Orphans", "Maintenance", fn report -> - {:ok, _import} = - Maintenance.import_metadata_diff_orphans(project.id, orphans, - on_progress: scaled_progress_reporter(report, 0.0, 0.8) - ) + queue_task( + project, + "import_metadata_diff_orphans", + "Import Metadata Diff Orphans", + "Maintenance", + fn report -> + {:ok, _import} = + Maintenance.import_metadata_diff_orphans(project.id, orphans, + on_progress: scaled_progress_reporter(report, 0.0, 0.8) + ) - {:ok, metadata_diff} = - Maintenance.metadata_diff(project.id, - on_progress: scaled_progress_reporter(report, 0.8, 0.98) - ) + {:ok, metadata_diff} = + Maintenance.metadata_diff(project.id, + on_progress: scaled_progress_reporter(report, 0.8, 0.98) + ) - report.(1.0, "Metadata diff import complete") - metadata_diff_result(project.id, metadata_diff) - end) + report.(1.0, "Metadata diff import complete") + metadata_diff_result(project.id, metadata_diff) + end + ) end end defp dispatch("validate_translations", project, _params) do - queue_task(project, "validate_translations", "Validate Translations", "Validation", fn report -> - {:ok, translation_report} = Posts.validate_translations(project.id, on_progress: report) - report.(1.0, "Translation validation complete") - translation_validation_result(project.id, translation_report) - end) + queue_task( + project, + "validate_translations", + "Validate Translations", + "Validation", + fn report -> + {:ok, translation_report} = Posts.validate_translations(project.id, on_progress: report) + report.(1.0, "Translation validation complete") + translation_validation_result(project.id, translation_report) + end + ) end defp dispatch("find_duplicates", project, _params) do @@ -342,7 +426,9 @@ defmodule BDS.Desktop.ShellCommands do %{ name: "Rebuild Media From Files", 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") %{project_id: project.id, counts: %{media: length(media)}} end @@ -350,7 +436,9 @@ defmodule BDS.Desktop.ShellCommands do %{ name: "Rebuild Scripts From Files", 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") %{project_id: project.id, counts: %{scripts: length(scripts)}} end @@ -358,7 +446,9 @@ defmodule BDS.Desktop.ShellCommands do %{ name: "Rebuild Templates From Files", 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") %{project_id: project.id, counts: %{templates: length(templates)}} end @@ -384,7 +474,12 @@ defmodule BDS.Desktop.ShellCommands do work: fn report -> {:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report) 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 } ] @@ -531,7 +626,10 @@ defmodule BDS.Desktop.ShellCommands do subtitle: "Database rows and translation files checked for invalid state", editorMeta: [ %{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) } @@ -564,8 +662,8 @@ defmodule BDS.Desktop.ShellCommands do defp normalize_metadata_diff_items(items) when is_list(items) do Enum.map(items, fn item -> %{ - entity_type: Map.get(item, :entity_type) || Map.get(item, "entity_type"), - entity_id: Map.get(item, :entity_id) || Map.get(item, "entity_id") + entity_type: BDS.MapUtils.attr(item, :entity_type), + entity_id: BDS.MapUtils.attr(item, :entity_id) } end) end @@ -574,7 +672,7 @@ defmodule BDS.Desktop.ShellCommands do defp normalize_metadata_diff_orphans(orphans) when is_list(orphans) do 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 @@ -593,7 +691,10 @@ defmodule BDS.Desktop.ShellCommands do 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} else {:error, %{action: "upload_site", message: "Publishing preferences are incomplete"}} diff --git a/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex b/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex index be912bc..d4a6004 100644 --- a/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex +++ b/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex @@ -4,12 +4,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do @tool_args_max_length 30 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 def tool_call_arguments(tool_call) when is_map(tool_call) do - Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || - Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{} + BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{} end 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) %{ - id: Map.get(tool_call, "id") || Map.get(tool_call, :id), + id: BDS.MapUtils.attr(tool_call, :id), name: tool_call_name(tool_call), arguments: arguments, args_preview: tool_arguments_preview(arguments), diff --git a/lib/bds/desktop/shell_live/cli_sync.ex b/lib/bds/desktop/shell_live/cli_sync.ex index 2b36bfc..6d9b43c 100644 --- a/lib/bds/desktop/shell_live/cli_sync.ex +++ b/lib/bds/desktop/shell_live/cli_sync.ex @@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.CliSync do import Phoenix.Component, only: [assign: 3] alias BDS.{Media, Posts} + alias BDS.MapUtils alias BDS.Media.Media, as: MediaRecord alias BDS.Posts.Post 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 called with `(socket, workbench)` to refresh derived data. """ - @spec apply_entity_change(Phoenix.LiveView.Socket.t(), map(), - (Phoenix.LiveView.Socket.t(), map() -> Phoenix.LiveView.Socket.t())) :: + @spec apply_entity_change(Phoenix.LiveView.Socket.t(), map(), (Phoenix.LiveView.Socket.t(), + map() -> + Phoenix.LiveView.Socket.t())) :: Phoenix.LiveView.Socket.t() 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 = - 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") - 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 action in [:created, :updated, :deleted] do @@ -45,13 +47,28 @@ defmodule BDS.Desktop.ShellLive.CliSync do |> assign(:shell_overlay, nil) |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id})) |> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id)) - |> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id)) - |> assign(:post_editor_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id)) - |> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id)) - |> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, post_id)) + |> assign( + :post_editor_active_languages, + Map.delete(socket.assigns.post_editor_active_languages, post_id) + ) + |> assign( + :post_editor_tag_queries, + Map.delete(socket.assigns.post_editor_tag_queries, post_id) + ) + |> assign( + :post_editor_category_queries, + Map.delete(socket.assigns.post_editor_category_queries, post_id) + ) + |> assign( + :post_editor_quick_actions_open, + Map.delete(socket.assigns.post_editor_quick_actions_open, post_id) + ) |> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id)) |> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id)) - |> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id)) + |> assign( + :post_editor_save_states, + Map.delete(socket.assigns.post_editor_save_states, post_id) + ) {socket, workbench} end @@ -65,31 +82,58 @@ defmodule BDS.Desktop.ShellLive.CliSync do |> assign(:shell_overlay, nil) |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id})) |> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id)) - |> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id)) - |> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id)) - |> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id)) - |> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id)) - |> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) + |> assign( + :media_editor_quick_actions_open, + Map.delete(socket.assigns.media_editor_quick_actions_open, media_id) + ) + |> assign( + :media_editor_post_pickers_open, + Map.delete(socket.assigns.media_editor_post_pickers_open, media_id) + ) + |> assign( + :media_editor_post_picker_queries, + Map.delete(socket.assigns.media_editor_post_picker_queries, media_id) + ) + |> assign( + :media_editor_save_states, + Map.delete(socket.assigns.media_editor_save_states, media_id) + ) + |> assign( + :media_editor_translation_forms, + Map.delete(socket.assigns.media_editor_translation_forms, media_id) + ) {socket, workbench} 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 -> case Posts.get_post(post_id) do - %Post{} = post -> %{title: post.title || post.slug || post.id, subtitle: Atom.to_string(post.status)} - _other -> nil + %Post{} = post -> + %{title: post.title || post.slug || post.id, subtitle: Atom.to_string(post.status)} + + _other -> + nil 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 -> case Media.get_media(media_id) do - %MediaRecord{} = media -> %{title: media.title || media.filename || media.id, subtitle: media.filename || media.mime_type || "media"} - _other -> nil + %MediaRecord{} = media -> + %{ + title: media.title || media.filename || media.id, + subtitle: media.filename || media.mime_type || "media" + } + + _other -> + nil 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 case meta_fun.() do %{} = 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) _other -> diff --git a/lib/bds/desktop/shell_live/misc_editor.ex b/lib/bds/desktop/shell_live/misc_editor.ex index 9d4a5ac..3db5809 100644 --- a/lib/bds/desktop/shell_live/misc_editor.ex +++ b/lib/bds/desktop/shell_live/misc_editor.ex @@ -7,11 +7,18 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do alias BDS.{Embeddings, Generation, Git, Posts, Repo} alias BDS.Desktop.ShellData + alias BDS.MapUtils 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 assign(socket, :misc_editor, build(socket.assigns)) @@ -19,7 +26,9 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do def rerun(socket) do case meta(socket.assigns) do - %{action: action} when is_binary(action) -> {:command, action} + %{action: action} when is_binary(action) -> + {:command, action} + _other -> case misc_route_action(socket.assigns.current_tab.type) do nil -> {:noop, socket} @@ -47,10 +56,16 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do {:ok, result} -> {:rerun, socket - |> append_output.(translated("Site Validation"), translated("Validation changes applied"), inspect(result))} + |> append_output.( + translated("Site Validation"), + translated("Validation changes applied"), + inspect(result) + )} end 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 def toggle_duplicate(socket, pair_id, reload) do @@ -65,7 +80,10 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end 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) end @@ -75,7 +93,9 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do socket |> update_payload(fn payload -> 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) |> 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 tab_id = socket.assigns.current_tab.id + selected = socket.assigns.misc_editor_selected_pairs |> 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) 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")) |> reload.(socket.assigns.workbench) @@ -137,14 +161,20 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do }) )} 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 def select_git_diff_file(socket, file_path) do assign( socket, :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 @@ -183,7 +213,10 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do meta = meta(socket.assigns) payload = Map.get(meta, :payload, %{}) 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) 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 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(:metadata_diff), do: "metadata-diff-view" @@ -255,11 +289,17 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do defp build_metadata_diff(assigns, meta, payload) do 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) active_tab = metadata_diff_active_tab(assigns, tabs) 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) %{ @@ -267,7 +307,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do title: Map.get(meta, :title, translated("Metadata Diff")), subtitle: Map.get(meta, :subtitle, ""), 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_field: active_field, repair_enabled: metadata_diff_repairable_tab?(current_tab.id), @@ -300,7 +341,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end 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, @@ -319,8 +361,15 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do {files, diff, error_message} = case Git.status(project_id) do {:ok, %{files: files}} -> - file_paths = 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) + file_paths = + 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 = case selected_file_path do @@ -329,8 +378,14 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do file_path -> case Git.get_diff_content(project_id, file_path) do - {:ok, diff} -> diff - {:error, reason} -> Map.merge(empty_git_diff(project_id), %{file_path: file_path, error: inspect(reason)}) + {:ok, diff} -> + diff + + {:error, reason} -> + Map.merge(empty_git_diff(project_id), %{ + file_path: file_path, + error: inspect(reason) + }) end end @@ -357,10 +412,17 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do def translation_issue_label(issue) do case issue_value(issue, :issue) do - "same-language-as-canonical" -> translated("translationValidation.issue.sameLanguage") - "do-not-translate-has-translations" -> translated("translationValidation.issue.doNotTranslate") - "content-in-database" -> translated("translationValidation.issue.contentInDatabase") - _other -> translated("translationValidation.issue.missingSource") + "same-language-as-canonical" -> + translated("translationValidation.issue.sameLanguage") + + "do-not-translate-has-translations" -> + translated("translationValidation.issue.doNotTranslate") + + "content-in-database" -> + translated("translationValidation.issue.contentInDatabase") + + _other -> + translated("translationValidation.issue.missingSource") end end @@ -414,12 +476,17 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do defp clear_selected_pair(socket, pair_id) do tab_id = socket.assigns.current_tab.id 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) end 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 case String.split(encoded, "::", parts: 2) do @@ -432,7 +499,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do defp field_summaries(items) do 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.map(fn {field, diffs} -> %{field_name: field, diff_count: length(diffs)} end) |> Enum.sort_by(&{&1.diff_count * -1, &1.field_name}) @@ -465,7 +532,15 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end 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 defp metadata_diff_active_tab(assigns, tabs) do @@ -489,11 +564,12 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do end defp normalize_metadata_diff_item(item) do - entity_type = Map.get(item, :entity_type) || Map.get(item, "entity_type") || "post" - entity_id = Map.get(item, :entity_id) || Map.get(item, "entity_id") || "" + entity_type = MapUtils.attr(item, :entity_type) || "post" + entity_id = MapUtils.attr(item, :entity_id) || "" + differences = item - |> Map.get(:differences, Map.get(item, "differences", [])) + |> MapUtils.attr(:differences, []) |> Enum.map(&normalize_metadata_diff_difference/1) %{ @@ -510,30 +586,31 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do defp normalize_metadata_diff_difference(diff) do %{ field: diff_name(diff), - db_value: format_metadata_diff_value(Map.get(diff, :db_value) || Map.get(diff, "db_value")), - file_value: format_metadata_diff_value(Map.get(diff, :file_value) || Map.get(diff, "file_value")) + db_value: format_metadata_diff_value(MapUtils.attr(diff, :db_value)), + file_value: format_metadata_diff_value(MapUtils.attr(diff, :file_value)) } end 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") || "" - entity_type = Map.get(orphan, :entity_type) || Map.get(orphan, "entity_type") || metadata_diff_orphan_entity_type(path) + path = MapUtils.attr(orphan, :file_path) || MapUtils.attr(orphan, :path) || "" + entity_type = MapUtils.attr(orphan, :entity_type) || metadata_diff_orphan_entity_type(path) %{ tab_id: metadata_diff_tab_id(entity_type), entity_type: entity_type, file_path: path, slug: Path.basename(path) |> String.trim(), - id: Map.get(orphan, :id) || Map.get(orphan, "id") + id: MapUtils.attr(orphan, :id) } end 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 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 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("category_meta"), do: translated("Categories") 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_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("project"), do: translated("Project") 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("media"), do: 1 @@ -588,7 +669,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do 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(: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 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 defp normalize_translation_validation_report(payload) when is_map(payload) do diff --git a/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex b/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex index 87b8a1e..5f56c88 100644 --- a/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex +++ b/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex @@ -261,11 +261,11 @@ <%= for pair <- @misc_editor.pairs do %>
- + - - <%= 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)}%") %> - + + <%= if(BDS.MapUtils.attr(pair, :exact_match), do: translated("Exact Match"), else: "#{Float.round((BDS.MapUtils.attr(pair, :similarity) || 0.0) * 100, 1)}%") %> +
<% end %> diff --git a/lib/bds/maintenance/repair.ex b/lib/bds/maintenance/repair.ex index c1b8642..622f43d 100644 --- a/lib/bds/maintenance/repair.ex +++ b/lib/bds/maintenance/repair.ex @@ -11,6 +11,7 @@ defmodule BDS.Maintenance.Repair do import BDS.Maintenance.Progress, only: [report_progress: 4] alias BDS.Embeddings + alias BDS.MapUtils alias BDS.Metadata def normalize_entity_type(:post), do: :post @@ -33,31 +34,62 @@ defmodule BDS.Maintenance.Repair do def normalize_repair_direction(_direction), do: :unsupported def repair_metadata_diff_item(project_id, direction, item) do - entity_type = Map.get(item, :entity_type) || Map.get(item, "entity_type") - entity_id = Map.get(item, :entity_id) || Map.get(item, "entity_id") + entity_type = MapUtils.attr(item, :entity_type) + entity_id = MapUtils.attr(item, :entity_id) 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) - {: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) - {:file_to_db, "post"} -> BDS.Posts.sync_post_from_file(entity_id) - {:db_to_file, "post"} -> BDS.Posts.rewrite_published_post(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) - {:file_to_db, "media"} -> BDS.Media.sync_media_from_sidecar(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) - {: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} + {:file_to_db, "post"} -> + BDS.Posts.sync_post_from_file(entity_id) + + {:db_to_file, "post"} -> + BDS.Posts.rewrite_published_post(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) + + {:file_to_db, "media"} -> + BDS.Media.sync_media_from_sidecar(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) + + {: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 @@ -87,7 +119,8 @@ defmodule BDS.Maintenance.Repair do 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 items @@ -106,15 +139,15 @@ defmodule BDS.Maintenance.Repair do end defp metadata_diff_item_entity_type(item) do - Map.get(item, :entity_type) || Map.get(item, "entity_type") + MapUtils.attr(item, :entity_type) end defp metadata_diff_item_entity_id(item) do - Map.get(item, :entity_id) || Map.get(item, "entity_id") + MapUtils.attr(item, :entity_id) end 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 is_nil(file_path) -> diff --git a/lib/bds/map_utils.ex b/lib/bds/map_utils.ex index 09de35e..01a5a42 100644 --- a/lib/bds/map_utils.ex +++ b/lib/bds/map_utils.ex @@ -13,6 +13,15 @@ defmodule BDS.MapUtils do 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() def maybe_put(map, _key, nil), do: map def maybe_put(map, key, value), do: Map.put(map, key, value) diff --git a/lib/bds/metadata.ex b/lib/bds/metadata.ex index ef8e610..396216e 100644 --- a/lib/bds/metadata.ex +++ b/lib/bds/metadata.ex @@ -173,7 +173,14 @@ defmodule BDS.Metadata do |> Repo.update!() 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( project_id, "category_meta", @@ -247,10 +254,15 @@ defmodule BDS.Metadata do read_json(project, "project.json") || 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 = - 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"} @@ -305,14 +317,11 @@ defmodule BDS.Metadata do defp normalize_category_settings(settings) do %{ - "render_in_lists" => - Map.get(settings, :render_in_lists, Map.get(settings, "render_in_lists", true)), - "show_title" => Map.get(settings, :show_title, Map.get(settings, "show_title", true)), - "post_template_slug" => - Map.get(settings, :post_template_slug, Map.get(settings, "post_template_slug")), - "list_template_slug" => - Map.get(settings, :list_template_slug, Map.get(settings, "list_template_slug")), - "title" => Map.get(settings, :title, Map.get(settings, "title")) + "render_in_lists" => attr(settings, :render_in_lists, true), + "show_title" => attr(settings, :show_title, true), + "post_template_slug" => attr(settings, :post_template_slug), + "list_template_slug" => attr(settings, :list_template_slug), + "title" => attr(settings, :title) } end @@ -447,7 +456,9 @@ defmodule BDS.Metadata do |> Map.new() 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(_payload), do: @default_categories @@ -459,13 +470,25 @@ defmodule BDS.Metadata do {category, %{ "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" => Map.get(category_settings, "show_title", Map.get(category_settings, "showTitle", true)), "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" => - 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") } |> Enum.reject(fn {_key, value} -> is_nil(value) end) @@ -480,10 +503,12 @@ defmodule BDS.Metadata do "publicUrl" => Map.get(project_metadata, "public_url"), "mainLanguage" => Map.get(project_metadata, "main_language"), "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"), "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", []) } |> 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({: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 project_metadata.semantic_similarity_enabled == true do {:ok, _indexed_post_ids} = Embeddings.index_unindexed(project_id) @@ -596,7 +626,8 @@ defmodule BDS.Metadata do result 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 cond do @@ -605,4 +636,12 @@ defmodule BDS.Metadata do true -> nil end end + + defp attr(attrs, key, default) do + cond do + Map.has_key?(attrs, key) -> Map.get(attrs, key) + Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key)) + true -> default + end + end end diff --git a/lib/bds/rendering/list_archive.ex b/lib/bds/rendering/list_archive.ex index dada25a..8fa9fb1 100644 --- a/lib/bds/rendering/list_archive.ex +++ b/lib/bds/rendering/list_archive.ex @@ -2,6 +2,7 @@ defmodule BDS.Rendering.ListArchive do @moduledoc false alias BDS.I18n + alias BDS.MapUtils alias BDS.Persistence alias BDS.Rendering.LinksAndLanguages alias BDS.Rendering.Metadata, as: RenderMetadata @@ -12,18 +13,19 @@ defmodule BDS.Rendering.ListArchive do metadata = RenderMetadata.project_metadata(project_id) template_context = TemplateSelection.template_render_context(project_id) - language = - Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + language = MapUtils.attr(assigns, :language, metadata.main_language || "en") 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) posts = normalize_list_posts( - Map.get(assigns, :posts, Map.get(assigns, "posts", [])), + MapUtils.attr(assigns, :posts, []), canonical_post_paths, canonical_media_paths, language, @@ -31,7 +33,7 @@ defmodule BDS.Rendering.ListArchive do ) 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) min_date = min_date(posts) @@ -44,15 +46,23 @@ defmodule BDS.Rendering.ListArchive do Map.get( assigns, :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, pico_stylesheet_href: Map.get( assigns, :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: Map.get( @@ -66,7 +76,8 @@ defmodule BDS.Rendering.ListArchive do calendar_initial_year: calendar_initial_year_from_posts(posts), calendar_initial_month: calendar_initial_month_from_posts(posts), 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, max_date: max_date, is_list_page: true, @@ -91,25 +102,32 @@ defmodule BDS.Rendering.ListArchive do def not_found_assigns(project_id, assigns) do metadata = RenderMetadata.project_metadata(project_id) - language = - Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + language = MapUtils.attr(assigns, :language, metadata.main_language || "en") 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_prefix: Map.get( assigns, :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: Map.get( assigns, :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: Map.get( @@ -143,20 +161,27 @@ defmodule BDS.Rendering.ListArchive do } 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 -> post_record = PostRendering.load_post_record(post) + raw_content = Map.get( post, :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")), - slug: Map.get(post, :slug, Map.get(post, "slug")), - title: Map.get(post, :title, Map.get(post, "title")), + id: MapUtils.attr(post, :id), + slug: MapUtils.attr(post, :slug), + title: MapUtils.attr(post, :title), content: PostRendering.render_post_content( raw_content, @@ -166,29 +191,30 @@ defmodule BDS.Rendering.ListArchive do template_context ), raw_content: raw_content, - excerpt: - Map.get(post, :excerpt, Map.get(post, "excerpt", Map.get(post_record || %{}, :excerpt))), - author: Map.get(post, :author, Map.get(post, "author", Map.get(post_record || %{}, :author))), + excerpt: MapUtils.attr(post, :excerpt, Map.get(post_record || %{}, :excerpt)), + author: MapUtils.attr(post, :author, Map.get(post_record || %{}, :author)), language: Map.get( post, :language, - Map.get(post, "language", Map.get(post_record || %{}, :language)) + Map.get(post_record || %{}, :language) ), published_at: - Map.get(post, :published_at, Map.get(post, "published_at", Map.get(post_record || %{}, :published_at))), - created_at: - Map.get(post, :created_at, Map.get(post, "created_at", Map.get(post_record || %{}, :created_at))), - updated_at: - 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, []))) || [], + MapUtils.attr(post, :published_at, Map.get(post_record || %{}, :published_at)), + created_at: MapUtils.attr(post, :created_at, Map.get(post_record || %{}, :created_at)), + updated_at: MapUtils.attr(post, :updated_at, Map.get(post_record || %{}, :updated_at)), + tags: MapUtils.attr(post, :tags, Map.get(post_record || %{}, :tags, [])) || [], 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: - 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: - Map.get(post, :do_not_translate, Map.get(post, "do_not_translate", Map.get(post_record || %{}, :do_not_translate, false))), - href: Map.get(post, :href, Map.get(post, "href")), + MapUtils.attr( + post, + :do_not_translate, + Map.get(post_record || %{}, :do_not_translate, false) + ), + href: MapUtils.attr(post, :href), show_title: true, linked_media: [], outgoing_links: [], @@ -214,24 +240,20 @@ defmodule BDS.Rendering.ListArchive do defp normalize_pagination(%{} = pagination, posts) do total_items = - Map.get(pagination, :total_items, Map.get(pagination, "total_items", length(posts))) + MapUtils.attr(pagination, :total_items, length(posts)) 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)), - total_pages: Map.get(pagination, :total_pages, Map.get(pagination, "total_pages", 1)), + current_page: MapUtils.attr(pagination, :current_page, 1), + total_pages: MapUtils.attr(pagination, :total_pages, 1), total_items: total_items, items_per_page: items_per_page, - has_prev_page: - Map.get(pagination, :has_prev_page, Map.get(pagination, "has_prev_page", false)), - prev_page_href: - Map.get(pagination, :prev_page_href, Map.get(pagination, "prev_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", "")) + has_prev_page: MapUtils.attr(pagination, :has_prev_page, false), + prev_page_href: MapUtils.attr(pagination, :prev_page_href, ""), + has_next_page: MapUtils.attr(pagination, :has_next_page, false), + next_page_href: MapUtils.attr(pagination, :next_page_href, "") } end @@ -239,11 +261,11 @@ defmodule BDS.Rendering.ListArchive do defp normalize_archive_context(%{} = archive_context) do %{ - kind: Map.get(archive_context, :kind, Map.get(archive_context, "kind")), - name: Map.get(archive_context, :name, Map.get(archive_context, "name")), - month: Map.get(archive_context, :month, Map.get(archive_context, "month")), - year: Map.get(archive_context, :year, Map.get(archive_context, "year")), - day: Map.get(archive_context, :day, Map.get(archive_context, "day")) + kind: MapUtils.attr(archive_context, :kind), + name: MapUtils.attr(archive_context, :name), + month: MapUtils.attr(archive_context, :month), + year: MapUtils.attr(archive_context, :year), + day: MapUtils.attr(archive_context, :day) } end @@ -251,7 +273,12 @@ defmodule BDS.Rendering.ListArchive do grouped_blocks = posts |> 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) 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?(_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_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 end diff --git a/lib/bds/rendering/post_rendering.ex b/lib/bds/rendering/post_rendering.ex index 7398a73..3327ad5 100644 --- a/lib/bds/rendering/post_rendering.ex +++ b/lib/bds/rendering/post_rendering.ex @@ -5,6 +5,7 @@ defmodule BDS.Rendering.PostRendering do alias BDS.Rendering.LinksAndLanguages alias BDS.Rendering.Metadata, as: RenderMetadata alias BDS.Rendering.TemplateSelection + alias BDS.MapUtils alias BDS.Posts.Post alias BDS.Posts.Translation alias BDS.Repo @@ -13,8 +14,7 @@ defmodule BDS.Rendering.PostRendering do metadata = RenderMetadata.project_metadata(project_id) template_context = TemplateSelection.template_render_context(project_id) - language = - Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + language = MapUtils.attr(assigns, :language, metadata.main_language || "en") main_language = metadata.main_language || language post_record = load_post_record(assigns) @@ -22,12 +22,27 @@ defmodule BDS.Rendering.PostRendering do post_id = canonical_post_id(post_record, assigns) post_categories = Map.get(post_record || %{}, :categories, []) || [] 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) - raw_content = Map.get(assigns, :content, Map.get(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) - outgoing_links = LinksAndLanguages.link_contexts(project_id, post_id, :outgoing, main_language) + 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) + + outgoing_links = + LinksAndLanguages.link_contexts(project_id, post_id, :outgoing, main_language) post_assigns = assigns @@ -40,19 +55,27 @@ defmodule BDS.Rendering.PostRendering do Map.get( assigns, :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", Map.get(assigns, :title, Map.get(assigns, "title"))) + MapUtils.attr(assigns, :title) ), pico_stylesheet_href: Map.get( assigns, :pico_stylesheet_href, - Map.get(assigns, "pico_stylesheet_href", RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme)) + Map.get( + assigns, + "pico_stylesheet_href", + RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme) + ) ), html_theme_attribute: Map.get( @@ -77,7 +100,7 @@ defmodule BDS.Rendering.PostRendering do end def load_post_record(assigns) do - case Map.get(assigns, :id, Map.get(assigns, "id")) do + case MapUtils.attr(assigns, :id) do nil -> nil post_id -> Repo.get(Post, post_id) || Repo.get(Translation, post_id) 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(%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 - id = Map.get(assigns, :id, Map.get(assigns, "id")) + id = MapUtils.attr(assigns, :id) 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)) - outgoing_links = LinksAndLanguages.link_contexts(Map.get(post_record || %{}, :project_id), canonical_post_id(post_record, assigns), :outgoing, Map.get(post_record || %{}, :language)) + incoming_links = + 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 %{} @@ -124,23 +163,23 @@ defmodule BDS.Rendering.PostRendering do defp build_post_context(assigns, post_record, incoming_links, outgoing_links) do %{ - id: Map.get(assigns, :id, Map.get(assigns, "id")), - slug: Map.get(assigns, :slug, Map.get(assigns, "slug")), - title: Map.get(assigns, :title, Map.get(assigns, "title")), - content: Map.get(assigns, :content, Map.get(assigns, "content")), - raw_content: Map.get(assigns, :raw_content, Map.get(assigns, "raw_content")), + id: MapUtils.attr(assigns, :id), + slug: MapUtils.attr(assigns, :slug), + title: MapUtils.attr(assigns, :title), + content: MapUtils.attr(assigns, :content), + raw_content: MapUtils.attr(assigns, :raw_content), excerpt: Map.get( assigns, :excerpt, - Map.get(assigns, "excerpt", Map.get(post_record || %{}, :excerpt)) + Map.get(post_record || %{}, :excerpt) ), author: Map.get(post_record || %{}, :author), language: Map.get( assigns, :language, - Map.get(assigns, "language", Map.get(post_record || %{}, :language)) + Map.get(post_record || %{}, :language) ), show_title: true, published_at: Map.get(post_record || %{}, :published_at), @@ -152,7 +191,7 @@ defmodule BDS.Rendering.PostRendering do Map.get( post_record || %{}, :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), linked_media: [], @@ -161,7 +200,19 @@ defmodule BDS.Rendering.PostRendering do } end - def render_post_content(content, canonical_post_paths, canonical_media_paths, language, template_context) do - Filters.render_markdown(content, canonical_post_paths, canonical_media_paths, language, template_context) + def render_post_content( + 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 diff --git a/lib/bds/ui/commands.ex b/lib/bds/ui/commands.ex index ffac82b..6bc46cb 100644 --- a/lib/bds/ui/commands.ex +++ b/lib/bds/ui/commands.ex @@ -32,13 +32,14 @@ defmodule BDS.UI.Commands do ] def handle_shortcut(state, shortcut) when is_map(shortcut) do - key = shortcut |> Map.get(:key, Map.get(shortcut, "key", "")) |> String.downcase() - primary = - Map.get(shortcut, :meta, Map.get(shortcut, "meta", false)) or - Map.get(shortcut, :ctrl, Map.get(shortcut, "ctrl", false)) + key = shortcut |> BDS.MapUtils.attr(:key, "") |> String.downcase() - shift = Map.get(shortcut, :shift, Map.get(shortcut, "shift", false)) - alt = Map.get(shortcut, :alt, Map.get(shortcut, "alt", false)) + primary = + 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 %{id: command_id} -> MenuBar.execute(state, command_id) diff --git a/lib/bds/ui/sidebar.ex b/lib/bds/ui/sidebar.ex index 7109a3b..0fa2c98 100644 --- a/lib/bds/ui/sidebar.ex +++ b/lib/bds/ui/sidebar.ex @@ -27,7 +27,13 @@ defmodule BDS.UI.Sidebar do "templates" => view(project_id, "templates"), "tags" => view(project_id, "tags"), "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(), "settings" => settings_nav_view() } @@ -41,17 +47,43 @@ defmodule BDS.UI.Sidebar do normalized_view = normalize_view_id(view_id) case normalized_view do - "posts" -> posts_view(project_id, params, false) - "pages" -> posts_view(project_id, params, true) - "media" -> media_view(project_id, params) - "scripts" -> 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) + "posts" -> + posts_view(project_id, params, false) + + "pages" -> + posts_view(project_id, params, true) + + "media" -> + media_view(project_id, params) + + "scripts" -> + 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 @@ -74,13 +106,18 @@ defmodule BDS.UI.Sidebar do 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("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("chat"), do: entity_list_view("Chat", "AI conversations", "chat", []) defp empty_view("import"), do: entity_list_view("Import", "Import definitions", "import", []) defp empty_view("git"), do: git_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 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) 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) grouped_posts = group_posts(limited_posts) available_tags = available_tags(base_posts, & &1.tags) @@ -101,12 +145,14 @@ defmodule BDS.UI.Sidebar do %{ 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", empty_message: if(pages?, do: "sidebar.noPagesYet", else: "sidebar.noPostsYet"), filters: %{ 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", archive_label: "render.archive", tags_label: "sidebar.tags", @@ -137,8 +183,20 @@ defmodule BDS.UI.Sidebar do }, sections: [ 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("Archived", :archived, grouped_posts.archived, translation_counts, false) + build_post_section( + "Published", + :published, + grouped_posts.published, + translation_counts, + true + ), + build_post_section( + "Archived", + :archived, + grouped_posts.archived, + translation_counts, + false + ) ] } end @@ -246,7 +304,13 @@ defmodule BDS.UI.Sidebar do layout: "entity_list", empty_message: "No 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 @@ -285,7 +349,8 @@ defmodule BDS.UI.Sidebar do tags: post.tags || [], status: Atom.to_string(post.status), 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", search_blob: post_search_blob(post) } @@ -377,7 +442,11 @@ defmodule BDS.UI.Sidebar do Repo.all( from conversation in ChatConversation, 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 @@ -402,15 +471,15 @@ defmodule BDS.UI.Sidebar do defp normalize_filter_params(params) when is_map(params) do %{ - search: normalize_string(Map.get(params, "search") || Map.get(params, :search)), - year: normalize_integer(Map.get(params, "year") || Map.get(params, :year)), - month: normalize_integer(Map.get(params, "month") || Map.get(params, :month)), - tags: normalize_string_list(Map.get(params, "tags") || Map.get(params, :tags)), - categories: normalize_string_list(Map.get(params, "categories") || Map.get(params, :categories)), + search: normalize_string(BDS.MapUtils.attr(params, :search)), + year: normalize_integer(BDS.MapUtils.attr(params, :year)), + month: normalize_integer(BDS.MapUtils.attr(params, :month)), + tags: normalize_string_list(BDS.MapUtils.attr(params, :tags)), + categories: normalize_string_list(BDS.MapUtils.attr(params, :categories)), display_limit: max( @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 @@ -418,11 +487,19 @@ defmodule BDS.UI.Sidebar do defp normalize_filter_params(_params), do: empty_filter_params() 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 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 defp apply_post_filters(posts, filters) do @@ -497,7 +574,9 @@ defmodule BDS.UI.Sidebar do posts |> Enum.flat_map(&filtered_categories(&1.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) |> Enum.map(&to_string/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_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.join(" ") end diff --git a/test/bds/map_utils_test.exs b/test/bds/map_utils_test.exs index 4a9d4ce..30e3753 100644 --- a/test/bds/map_utils_test.exs +++ b/test/bds/map_utils_test.exs @@ -10,6 +10,12 @@ defmodule BDS.MapUtilsTest do assert MapUtils.attr(%{"title" => "fallback", title: nil}, :title) == nil assert MapUtils.attr(%{}, :title) == nil 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 describe "maybe_put/3" do @@ -28,4 +34,47 @@ defmodule BDS.MapUtilsTest do assert MapUtils.blank_to_nil(42) == 42 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