diff --git a/README.md b/README.md index eb45440..27a2bb6 100644 --- a/README.md +++ b/README.md @@ -130,9 +130,12 @@ The scripts do the standard sequence: 1. `mix deps.get` 2. `mix test` unless `BDS_SKIP_TESTS=1` -3. `MIX_ENV=prod mix release bds` -4. `MIX_ENV=prod mix release bds_mcp` -5. `MIX_ENV=prod mix bds.package ` +3. `mix dialyzer` unless `BDS_SKIP_DIALYZER=1` +4. `MIX_ENV=prod mix release bds` +5. `MIX_ENV=prod mix release bds_mcp` +6. `MIX_ENV=prod mix bds.package ` + +For local CLI validation without packaging, use `mix validate`. The packaging task creates a clean redistributable payload under `dist//` with this layout: diff --git a/lib/bds/ai.ex b/lib/bds/ai.ex index 539f6c3..b009bf5 100644 --- a/lib/bds/ai.ex +++ b/lib/bds/ai.ex @@ -991,12 +991,14 @@ defmodule BDS.AI do end defp await_chat_task(task) do + ref = task.ref + receive do - {ref, result} when ref == task.ref -> + {^ref, result} -> Process.demonitor(task.ref, [:flush]) result - {:DOWN, ref, :process, _pid, reason} when ref == task.ref -> + {:DOWN, ^ref, :process, _pid, reason} -> case reason do :normal -> receive do diff --git a/lib/bds/application.ex b/lib/bds/application.ex index 762e9f0..18aa35a 100644 --- a/lib/bds/application.ex +++ b/lib/bds/application.ex @@ -3,6 +3,8 @@ defmodule BDS.Application do use Application + @compiled_env Application.compile_env(:bds, :current_env, Mix.env()) + def desktop_children(env \\ nil) def desktop_children(:test) do @@ -41,8 +43,7 @@ defmodule BDS.Application do end defp current_env do - Application.get_env(:bds, :current_env_override) || - if(Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod) + Application.get_env(:bds, :current_env_override) || @compiled_env end defp desktop_window_children do diff --git a/lib/bds/desktop/automation.ex b/lib/bds/desktop/automation.ex index 720ed8b..8f2c9e2 100644 --- a/lib/bds/desktop/automation.ex +++ b/lib/bds/desktop/automation.ex @@ -286,8 +286,6 @@ defmodule BDS.Desktop.Automation do end end - defp port_os_pid(nil), do: nil - defp port_os_pid(port) do case Port.info(port, :os_pid) do {:os_pid, pid} when is_integer(pid) -> pid diff --git a/lib/bds/desktop/main_window.ex b/lib/bds/desktop/main_window.ex index 2ba51c7..7a4605a 100644 --- a/lib/bds/desktop/main_window.ex +++ b/lib/bds/desktop/main_window.ex @@ -213,10 +213,8 @@ defmodule BDS.Desktop.MainWindow do defp wx_env_undefined? do try do - case :wx.get_env() do - :undefined -> true - _ -> false - end + _ = :wx.get_env() + false rescue ErlangError -> true end diff --git a/lib/bds/git.ex b/lib/bds/git.ex index 4d1d740..4d25eaf 100644 --- a/lib/bds/git.ex +++ b/lib/bds/git.ex @@ -117,7 +117,6 @@ defmodule BDS.Git do case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do {:ok, output} -> {:ok, %{updated: true, output: output}} {:error, {:git_failed, message}} -> structured_git_error(project_dir, :fetch, message, opts) - other -> other end end end @@ -209,7 +208,6 @@ defmodule BDS.Git do case run_git(project_dir, ["remote", "get-url", "origin"], opts) do {:ok, output} -> {:ok, blank_to_nil(output)} {:error, {:git_failed, _message}} -> {:ok, nil} - other -> other end end @@ -217,7 +215,6 @@ defmodule BDS.Git do case run_git(project_dir, ["lfs", "ls-files"], opts) do {:ok, _output} -> {:ok, true} {:error, {:git_failed, _message}} -> {:ok, false} - other -> other end end @@ -335,7 +332,6 @@ defmodule BDS.Git do provider = case remote_url(project_dir, opts) do {:ok, remote} -> provider_info(remote) - _other -> nil end if auth_error?(message) do @@ -370,9 +366,8 @@ defmodule BDS.Git do end defp normalize_optional_string({:ok, value}), do: {:ok, blank_to_nil(value)} - defp normalize_optional_string(other), do: other + defp normalize_optional_string({:error, _reason} = error), do: error - defp blank_to_nil(nil), do: nil defp blank_to_nil(value) when value in ["", "\n"], do: nil defp blank_to_nil(value), do: String.trim(value) end diff --git a/lib/bds/mcp/server.ex b/lib/bds/mcp/server.ex index ac6223b..ec89c83 100644 --- a/lib/bds/mcp/server.ex +++ b/lib/bds/mcp/server.ex @@ -217,9 +217,6 @@ defmodule BDS.MCP.Server do {:error, :not_found} -> {:error, error_response(id, -32004, "Not found")} - - {:error, reason} -> - {:error, error_response(id, -32000, inspect(reason))} end end @@ -292,9 +289,7 @@ defmodule BDS.MCP.Server do defp http_error_response(status, headers \\ %{}), do: http_response(status, reason_body(status), "text/plain", headers) defp reason_body(400), do: "Bad Request" - defp reason_body(403), do: "Forbidden" defp reason_body(404), do: "Not Found" - defp reason_body(_status), do: "Internal Server Error" defp maybe_allow_repo(owner_pid) do try do diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index bb03a71..f6963bb 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -139,12 +139,21 @@ defmodule BDS.Posts do def rebuild_posts_from_files(project_id) do project = Projects.get_project!(project_id) - posts = + rebuild_files = project |> Projects.project_data_dir() |> Path.join("posts") |> list_matching_files("*.md") - |> Enum.map(&upsert_post_from_file(project_id, project, &1)) + |> Enum.map(&parse_rebuild_file(project, &1)) + + {translation_files, post_files} = Enum.split_with(rebuild_files, &translation_rebuild_file?/1) + + posts = + post_files + |> Enum.map(&upsert_post_from_rebuild_file(project_id, &1)) + + translation_files + |> Enum.map(&upsert_post_translation_from_rebuild_file(project_id, &1)) {:ok, posts} end @@ -632,9 +641,13 @@ defmodule BDS.Posts do end defp upsert_post_from_file(project_id, project, path) do - contents = File.read!(path) - {:ok, %{fields: fields}} = Frontmatter.parse_document(contents) - relative_path = Path.relative_to(path, Projects.project_data_dir(project)) + project + |> parse_rebuild_file(path) + |> upsert_post_from_rebuild_file(project_id) + end + + defp upsert_post_from_rebuild_file(project_id, rebuild_file) do + fields = rebuild_file.fields now = Persistence.now_ms() attrs = %{ @@ -649,7 +662,7 @@ defmodule BDS.Posts do created_at: Map.get(fields, "created_at", now), updated_at: Map.get(fields, "updated_at", now), published_at: Map.get(fields, "published_at"), - file_path: relative_path, + file_path: rebuild_file.relative_path, checksum: nil, tags: Map.get(fields, "tags", []), categories: Map.get(fields, "categories", []), @@ -672,9 +685,85 @@ defmodule BDS.Posts do |> tap(&Embeddings.sync_post/1) end + defp upsert_post_translation_from_rebuild_file(project_id, rebuild_file) do + fields = rebuild_file.fields + source_post_id = Map.fetch!(fields, "translation_for") + source_post = Repo.get!(Post, source_post_id) + now = Persistence.now_ms() + language = normalize_language(Map.fetch!(fields, "language")) + + translation = + Repo.get_by(Translation, translation_for: source_post_id, language: language) || %Translation{} + + attrs = %{ + id: Map.get(fields, "id") || Ecto.UUID.generate(), + project_id: project_id, + translation_for: source_post_id, + language: language, + title: Map.get(fields, "title") || "", + excerpt: Map.get(fields, "excerpt"), + content: nil, + status: parse_translation_status(Map.get(fields, "status", "published")), + created_at: Map.get(fields, "created_at", source_post.created_at || now), + updated_at: Map.get(fields, "updated_at", source_post.updated_at || source_post.created_at || now), + published_at: Map.get(fields, "published_at", source_post.published_at), + file_path: rebuild_file.relative_path, + checksum: nil + } + + translation + |> Translation.changeset(attrs) + |> Repo.insert_or_update!() + |> tap(fn _translation -> + :ok = Search.sync_post(source_post_id) + end) + end + defp parse_post_status(status) when is_atom(status), do: status defp parse_post_status(status), do: String.to_existing_atom(status) + defp parse_translation_status(status) when is_atom(status), do: status + defp parse_translation_status(status), do: String.to_existing_atom(status) + + defp parse_rebuild_file(project, path) do + contents = File.read!(path) + {:ok, %{fields: fields}} = Frontmatter.parse_document(contents) + + %{ + path: path, + relative_path: Path.relative_to(path, Projects.project_data_dir(project)), + fields: normalize_rebuild_fields(fields) + } + end + + defp translation_rebuild_file?(%{fields: fields}) do + Map.has_key?(fields, "translation_for") and not Map.has_key?(fields, "slug") + end + + defp normalize_rebuild_fields(fields) when is_map(fields) do + [ + {"translationFor", "translation_for"}, + {"doNotTranslate", "do_not_translate"}, + {"templateSlug", "template_slug"}, + {"createdAt", "created_at"}, + {"updatedAt", "updated_at"}, + {"publishedAt", "published_at"} + ] + |> Enum.reduce(fields, fn {legacy_key, current_key}, acc -> + case Map.fetch(acc, legacy_key) do + {:ok, value} -> Map.put_new(acc, current_key, normalize_rebuild_field_value(current_key, value)) + :error -> acc + end + end) + end + + defp normalize_rebuild_field_value(key, value) + when key in ["created_at", "updated_at", "published_at"] do + Persistence.parse_timestamp(value) || value + end + + defp normalize_rebuild_field_value(_key, value), do: value + defp list_matching_files(dir, pattern) do if File.dir?(dir) do Path.join([dir, "**", pattern]) diff --git a/lib/bds/rendering.ex b/lib/bds/rendering.ex index 5ae3d2c..37b4d4d 100644 --- a/lib/bds/rendering.ex +++ b/lib/bds/rendering.ex @@ -128,7 +128,6 @@ defmodule BDS.Rendering do case Frontmatter.parse_document(source) do {:ok, %{body: body}} -> {:ok, body} {:error, :invalid_frontmatter} -> {:ok, source} - {:error, reason} -> {:error, reason} end else {:error, :template_not_found} diff --git a/lib/bds/scripting/capabilities.ex b/lib/bds/scripting/capabilities.ex index da4dfad..666782a 100644 --- a/lib/bds/scripting/capabilities.ex +++ b/lib/bds/scripting/capabilities.ex @@ -1,6 +1,5 @@ defmodule BDS.Scripting.Capabilities do @moduledoc false - @mix_env Mix.env() import Ecto.Query @@ -495,7 +494,6 @@ defmodule BDS.Scripting.Capabilities do defp rebuild_post_links(project_id) do case Posts.rebuild_post_links(project_id) do :ok -> true - _other -> false end end @@ -508,7 +506,6 @@ defmodule BDS.Scripting.Capabilities do defp reindex_project_search(project_id) do case Search.reindex_project(project_id) do :ok -> true - _other -> false end end @@ -1018,10 +1015,10 @@ defmodule BDS.Scripting.Capabilities do end defp do_copy_to_clipboard(text) do - if @mix_env == :test do + if test_mode?() do true else - command = string_or_nil(text) || "" + command = string_or_nil(text) case :os.type() do {:unix, :darwin} -> match?({_output, 0}, System.cmd("pbcopy", [], input: command, stderr_to_stdout: true)) @@ -1060,16 +1057,16 @@ defmodule BDS.Scripting.Capabilities do defp open_folder(folder_path, opts) do case Keyword.get(opts, :open_folder) do - callback when is_function(callback, 1) -> callback.(string_or_nil(folder_path) || "") + callback when is_function(callback, 1) -> callback.(string_or_nil(folder_path)) _other -> do_open_folder(folder_path) end end defp do_open_folder(folder_path) do - if @mix_env == :test do + if test_mode?() do "" else - case open_system_path(string_or_nil(folder_path) || "") do + case shell_open_system_path(string_or_nil(folder_path)) do :ok -> "" {:error, reason} -> inspect(reason) end @@ -1084,7 +1081,7 @@ defmodule BDS.Scripting.Capabilities do end defp do_select_folder(title) do - if @mix_env == :test do + if test_mode?() do nil else case FolderPicker.choose_directory(string_or_nil(title) || "Select Folder") do @@ -1104,9 +1101,9 @@ defmodule BDS.Scripting.Capabilities do callback = Keyword.get(opts, :show_item_in_folder) cond do - is_function(callback, 1) -> callback.(string_or_nil(item_path) || "") - @mix_env == :test -> :ok - true -> _ = reveal_system_path(string_or_nil(item_path) || "") + is_function(callback, 1) -> callback.(string_or_nil(item_path)) + test_mode?() -> :ok + true -> _ = shell_reveal_system_path(string_or_nil(item_path)) end nil @@ -1116,9 +1113,9 @@ defmodule BDS.Scripting.Capabilities do callback = Keyword.get(opts, :trigger_menu_action) cond do - is_function(callback, 1) -> callback.(string_or_nil(action) || "") - @mix_env == :test -> :ok - true -> _ = MenuBar.handle_event(string_or_nil(action) || "", nil) + is_function(callback, 1) -> callback.(string_or_nil(action)) + test_mode?() -> :ok + true -> _ = MenuBar.handle_event(string_or_nil(action), nil) end nil @@ -1674,7 +1671,7 @@ defmodule BDS.Scripting.Capabilities do end defp parse_datetime(_value), do: nil - defp open_system_path(path) do + defp shell_open_system_path(path) do {command, args} = case :os.type() do {:unix, :darwin} -> {"open", [path]} @@ -1690,7 +1687,7 @@ defmodule BDS.Scripting.Capabilities do error -> {:error, error} end - defp reveal_system_path(path) do + defp shell_reveal_system_path(path) do {command, args} = case :os.type() do {:unix, :darwin} -> {"open", ["-R", path]} @@ -1705,4 +1702,8 @@ defmodule BDS.Scripting.Capabilities do rescue error -> {:error, error} end + + defp test_mode? do + Application.get_env(:bds, :test_mode, false) + end end diff --git a/lib/bds/scripting/lua.ex b/lib/bds/scripting/lua.ex index 5d158f8..26a2dc2 100644 --- a/lib/bds/scripting/lua.ex +++ b/lib/bds/scripting/lua.ex @@ -195,9 +195,7 @@ defmodule BDS.Scripting.Lua do case values do [] -> nil [value] -> value - _other -> values + _values -> values end end - - defp unwrap_result(values), do: values end diff --git a/lib/bds/tasks.ex b/lib/bds/tasks.ex index c4d84ab..e289554 100644 --- a/lib/bds/tasks.ex +++ b/lib/bds/tasks.ex @@ -350,7 +350,10 @@ defmodule BDS.Tasks do defp public_task(nil), do: nil defp public_task(task) do - Map.drop(task, [:last_reported_at]) + task + |> Map.drop([:last_reported_at]) + |> Map.update(:error, nil, &json_safe_value/1) + |> Map.update(:result, nil, &json_safe_value/1) end defp build_status_snapshot(state) do @@ -419,6 +422,29 @@ defmodule BDS.Tasks do defp normalize_result({:error, _reason} = result), do: result defp normalize_result(value), do: {:ok, value} + defp json_safe_value(value) when is_nil(value), do: nil + defp json_safe_value(value) when is_binary(value), do: value + defp json_safe_value(value) when is_boolean(value), do: value + defp json_safe_value(value) when is_number(value), do: value + defp json_safe_value(value) when is_atom(value), do: value + + defp json_safe_value(value) when is_list(value) do + Enum.map(value, &json_safe_value/1) + end + + defp json_safe_value(value) when is_struct(value), do: inspect(value) + + defp json_safe_value(value) when is_map(value) do + Map.new(value, fn {key, item} -> {json_safe_key(key), json_safe_value(item)} end) + end + + defp json_safe_value(value) when is_tuple(value), do: inspect(value) + defp json_safe_value(value), do: inspect(value) + + defp json_safe_key(key) when is_binary(key), do: key + defp json_safe_key(key) when is_atom(key), do: key + defp json_safe_key(key), do: inspect(key) + defp max_concurrent do Application.get_env(:bds, :tasks, []) |> Keyword.get(:max_concurrent, @default_max_concurrent) diff --git a/mix.exs b/mix.exs index f1c5e81..16f1dba 100644 --- a/mix.exs +++ b/mix.exs @@ -9,6 +9,7 @@ defmodule BDS.MixProject do default_release: :bds, releases: releases(), start_permanent: Mix.env() == :prod, + dialyzer: dialyzer(), aliases: aliases(), deps: deps() ] @@ -16,7 +17,7 @@ defmodule BDS.MixProject do def application do [ - extra_applications: [:logger, :wx], + extra_applications: [:logger, :wx, :inets, :ssl], mod: {BDS.Application, []} ] end @@ -33,7 +34,8 @@ defmodule BDS.MixProject do {:bandit, "~> 1.5"}, {:desktop, "~> 1.5"}, {:image, "~> 0.65"}, - {:stemex, "~> 0.2.1"} + {:stemex, "~> 0.2.1"}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} ] end @@ -42,7 +44,17 @@ defmodule BDS.MixProject do setup: ["deps.get", "ecto.setup"], "ecto.setup": ["ecto.create", "ecto.migrate"], "ecto.reset": ["ecto.drop", "ecto.setup"], - test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + validate: ["test", "dialyzer"] + ] + end + + defp dialyzer do + env = Mix.env() + + [ + plt_add_apps: [:mix, :inets, :ssl], + paths: ["_build/#{env}/lib/bds/ebin"] ] end diff --git a/mix.lock b/mix.lock index 3cb63b8..6c534c8 100644 --- a/mix.lock +++ b/mix.lock @@ -8,11 +8,13 @@ "debouncer": {:hex, :debouncer, "0.1.13", "af5906b231c196943ac8386b5b5f45a2f36d54a8bcd7e1b29eef2671de33d287", [:mix], [], "hexpm", "a14f57420c7d4a287f8f08e715fc8759b5d28dcd1032f9585d57c45d22123382"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"}, "ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"}, "ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"}, diff --git a/scripts/release/build_platform.bat b/scripts/release/build_platform.bat index 87ee60e..53c8c40 100644 --- a/scripts/release/build_platform.bat +++ b/scripts/release/build_platform.bat @@ -15,6 +15,11 @@ if not "%BDS_SKIP_TESTS%"=="1" ( if errorlevel 1 goto :error ) +if not "%BDS_SKIP_DIALYZER%"=="1" ( + call mix dialyzer + if errorlevel 1 goto :error +) + call set MIX_ENV=prod call mix release --overwrite bds if errorlevel 1 goto :error diff --git a/scripts/release/build_platform.sh b/scripts/release/build_platform.sh index 327cfaa..0bb709d 100755 --- a/scripts/release/build_platform.sh +++ b/scripts/release/build_platform.sh @@ -23,6 +23,10 @@ if [[ "${BDS_SKIP_TESTS:-0}" != "1" ]]; then mix test fi +if [[ "${BDS_SKIP_DIALYZER:-0}" != "1" ]]; then + mix dialyzer +fi + MIX_ENV=prod mix release --overwrite bds MIX_ENV=prod mix release --overwrite bds_mcp MIX_ENV=prod mix bds.package "$PLATFORM" diff --git a/test/bds/desktop_test.exs b/test/bds/desktop_test.exs index 30c215f..da1d91b 100644 --- a/test/bds/desktop_test.exs +++ b/test/bds/desktop_test.exs @@ -121,6 +121,8 @@ defmodule BDS.DesktopTest do end test "desktop router exposes live task status for shell polling" do + :ok = BDS.Tasks.clear_finished() + assert {:ok, task} = BDS.Tasks.register_external_task("preview build", %{ group_id: "generation", @@ -129,6 +131,7 @@ defmodule BDS.DesktopTest do on_exit(fn -> _ = BDS.Tasks.complete_task(task.id) + _ = BDS.Tasks.clear_finished() end) assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "halfway") @@ -149,6 +152,39 @@ defmodule BDS.DesktopTest do end) end + test "desktop router encodes failed task snapshots even when the task error is a tuple" do + :ok = BDS.Tasks.clear_finished() + + assert {:ok, task} = + BDS.Tasks.submit_task( + "broken rebuild", + fn _report -> + {:error, {{:badkey, "slug"}, [{BDS.Posts, :upsert_post_from_file, 3, [line: 644]}]}} + end, + %{group_id: "maintenance", group_name: "Maintenance"} + ) + + on_exit(fn -> + _ = BDS.Tasks.clear_finished() + end) + + failed = wait_for_task(task.id, &(&1.status == :failed and &1.error != nil)) + + conn = conn(:get, "/api/tasks?k=#{Desktop.Auth.login_key()}") + conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([])) + + assert conn.status == 200 + payload = Jason.decode!(conn.resp_body) + + assert Enum.any?(payload["tasks"], fn item -> + item["id"] == failed.id and item["status"] == "failed" and is_binary(item["error"]) + end) + + assert Enum.any?(payload["tasks"], fn item -> + item["id"] == failed.id and String.contains?(item["error"], "badkey") + end) + end + test "desktop router exposes projects for shell project selection and creation" do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) BDS.Repo.delete_all(BDS.Projects.Project) @@ -281,4 +317,21 @@ defmodule BDS.DesktopTest do |> Enum.flat_map(& &1.items) |> Enum.find(&Map.get(&1, :id) == id) end + + defp wait_for_task(task_id, matcher, timeout \\ 2_000) + + defp wait_for_task(task_id, _matcher, timeout) when timeout <= 0 do + BDS.Tasks.get_task(task_id) + end + + defp wait_for_task(task_id, matcher, timeout) do + task = BDS.Tasks.get_task(task_id) + + if task && matcher.(task) do + task + else + Process.sleep(50) + wait_for_task(task_id, matcher, timeout - 50) + end + end end diff --git a/test/bds/posts_test.exs b/test/bds/posts_test.exs index 42af643..4ad7a36 100644 --- a/test/bds/posts_test.exs +++ b/test/bds/posts_test.exs @@ -313,6 +313,77 @@ defmodule BDS.PostsTest do assert post.content == nil end + test "rebuild_posts_from_files imports legacy old-app translation files alongside canonical posts" do + temp_dir = + Path.join(System.tmp_dir!(), "bds-post-rebuild-legacy-#{System.unique_integer([:positive])}") + + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + assert {:ok, project} = + BDS.Projects.create_project(%{name: "Legacy Rebuild", data_path: temp_dir}) + + posts_dir = Path.join([BDS.Projects.project_data_dir(project), "posts", "2026", "04"]) + File.mkdir_p!(posts_dir) + + File.write!( + Path.join(posts_dir, "chimera.md"), + [ + "---", + "id: post-from-old-app", + "title: Chimera Source", + "slug: chimera", + "status: published", + "language: de", + "createdAt: 2024-03-30T21:20:00.000Z", + "updatedAt: 2024-03-31T21:20:00.000Z", + "publishedAt: 2024-04-01T21:20:00.000Z", + "---", + "Quelle", + "" + ] + |> Enum.join("\n") + ) + + File.write!( + Path.join(posts_dir, "chimera.en.md"), + [ + "---", + "id: translation-from-old-app", + "translationFor: post-from-old-app", + "language: en", + "title: Chimera", + "excerpt: Imported translation", + "---", + "Translated body", + "" + ] + |> Enum.join("\n") + ) + + assert {:ok, posts} = BDS.Posts.rebuild_posts_from_files(project.id) + assert length(posts) == 1 + + [post] = posts + assert post.id == "post-from-old-app" + assert post.slug == "chimera" + assert post.language == "de" + + assert {:ok, translations} = BDS.Posts.list_post_translations(post.id) + assert length(translations) == 1 + + [translation] = translations + assert translation.id == "translation-from-old-app" + assert translation.translation_for == post.id + assert translation.project_id == project.id + assert translation.language == "en" + assert translation.title == "Chimera" + assert translation.excerpt == "Imported translation" + assert translation.status == :published + assert translation.file_path == "posts/2026/04/chimera.en.md" + assert translation.content == nil + end + defp errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> Regex.replace(~r"%{(\w+)}", message, fn _, key ->