Compare commits

...

2 Commits

Author SHA1 Message Date
f6425de51d chore: merged different progress reporters
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 17:12:49 +02:00
79ee67c2e0 chore: extraction and centralization of utility functions 2026-05-01 17:04:21 +02:00
18 changed files with 601 additions and 424 deletions

View File

@@ -75,17 +75,9 @@ _None._ All modules previously on the queue have been split; refresh the queue i
## 6. Duplicate Helpers Across Contexts ## 6. Duplicate Helpers Across Contexts
**Status:** open. **Status:** ✅ done (2026-05-10). Shared atom-or-string map helpers now live in `BDS.MapUtils`; shared two-arity progress callback extraction, count-based progress math, message shaping, scaling, phase reporting, and rebuild wrappers now live in `BDS.ProgressReporter`.
**Duplicated functions (verified copy-pasted across `Posts`, `Media`, `Search`, `Generation`, `Publishing`, `MCP`):** **Scope closed:** `Posts`, `Media`, `Search`, `Generation`, `Publishing`, `MCP`, plus the same rebuild helper copies in `Scripts`, `Templates`, and `Embeddings`. Domain modules may keep thin wrappers for readability, but progress behavior is parameterized through the shared reporter. Remaining helpers with similar names are intentionally different local UI/import trimming helpers or non-progress map utilities.
- `attr/2` (atom-or-string key access)
- `maybe_put/3`
- `blank_to_nil/1`
- `progress_callback/1`
- `report_rebuild_started/3`, `report_rebuild_progress/4`
**Plan:** consolidate into `BDS.MapUtils` (attr / maybe_put / blank_to_nil) and `BDS.ProgressReporter` (callback + reporters). Note: `Util` modules already exist inside `BDS.Generation`, `BDS.Scripting.Capabilities`, and `BDS.Posts.*`; the goal is one shared utility, not three more.
--- ---
@@ -171,6 +163,8 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search`
### 2026-05-10 ### 2026-05-10
- **Duplicate helpers across contexts**: added `BDS.MapUtils` (`attr/2`, `maybe_put/3`, `blank_to_nil/1`) and expanded `BDS.ProgressReporter` (`callback/1`, `scaled/3`, `report_count_started/4`, `report_count_progress/5`, `report_rebuild_started/3`, `report_rebuild_progress/4`, `report_phase/3`). Replaced copy-pasted helpers in posts, post translations, post rebuild, media file ops, search, generation progress, maintenance progress, embeddings/index progress, publishing, MCP util, scripts, and templates. Domain-specific modules now express their wording/ranges as options to the shared reporter, preserving existing user-facing progress messages while sharing one implementation. Added focused tests for both shared modules. Section 6 is closed.
- **Bang file operations in long-running code**: `BDS.Media.Sidecars.parse_canonical_sidecar/2` and `parse_translation_sidecar/1` now use `File.read/1` and return `{:ok, sidecar}` or `{:error, {:read_sidecar, path, reason}}` instead of raising. Media rebuild collects parsed sidecars and returns the first sidecar read error before mutating rows; media sync/import sidecar entrypoints propagate the same errors. Added regression coverage for an unreadable `.meta` sidecar directory preserving the worker instead of crashing it. - **Bang file operations in long-running code**: `BDS.Media.Sidecars.parse_canonical_sidecar/2` and `parse_translation_sidecar/1` now use `File.read/1` and return `{:ok, sidecar}` or `{:error, {:read_sidecar, path, reason}}` instead of raising. Media rebuild collects parsed sidecars and returns the first sidecar read error before mutating rows; media sync/import sidecar entrypoints propagate the same errors. Added regression coverage for an unreadable `.meta` sidecar directory preserving the worker instead of crashing it.
- **Bang file operations in long-running code**: `BDS.Posts.RebuildFromFiles.parse_rebuild_file/2` now uses `File.read/1` and returns `{:ok, rebuild_file}` or `{:error, {:read_rebuild_file, path, reason}}`; post rebuild/import/sync-from-file callers propagate the tagged error. `BDS.Generation.apply_validation/2` now hashes existing generated files with `File.read/1` and returns `{:error, {:read_generated_file, path, reason}}` on read failures. `BDS.MCP.AgentConfig` now uses `File.mkdir_p/1`, `File.read/1`, `Jason.decode/1`, and `File.write/2`, returning tagged config read/write/create/decode errors instead of raising; the settings editor reports those errors through its existing output surface and its config probe no longer uses bang reads. Added regressions for unreadable post files, unreadable generated files, unreadable MCP config, and unwritable MCP config. Section 5 is closed. - **Bang file operations in long-running code**: `BDS.Posts.RebuildFromFiles.parse_rebuild_file/2` now uses `File.read/1` and returns `{:ok, rebuild_file}` or `{:error, {:read_rebuild_file, path, reason}}`; post rebuild/import/sync-from-file callers propagate the tagged error. `BDS.Generation.apply_validation/2` now hashes existing generated files with `File.read/1` and returns `{:error, {:read_generated_file, path, reason}}` on read failures. `BDS.MCP.AgentConfig` now uses `File.mkdir_p/1`, `File.read/1`, `Jason.decode/1`, and `File.write/2`, returning tagged config read/write/create/decode errors instead of raising; the settings editor reports those errors through its existing output surface and its config probe no longer uses bang reads. Added regressions for unreadable post files, unreadable generated files, unreadable MCP config, and unwritable MCP config. Section 5 is closed.

View File

@@ -9,6 +9,7 @@ defmodule BDS.Embeddings do
alias BDS.Embeddings.Key alias BDS.Embeddings.Key
alias BDS.Metadata alias BDS.Metadata
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.ProgressReporter
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
@@ -87,7 +88,11 @@ defmodule BDS.Embeddings do
on_progress = progress_callback(opts) on_progress = progress_callback(opts)
posts = posts =
Repo.all(from post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at, asc: post.slug]) Repo.all(
from post in Post,
where: post.project_id == ^project_id,
order_by: [asc: post.created_at, asc: post.slug]
)
post_ids = Enum.map(posts, & &1.id) post_ids = Enum.map(posts, & &1.id)
total_posts = length(posts) total_posts = length(posts)
@@ -162,7 +167,8 @@ defmodule BDS.Embeddings do
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
%Key{content_hash: ^content_hash} -> %Key{content_hash: ^content_hash} ->
if Keyword.get(opts, :refresh_index, true) and snapshot_content_hash(post.project_id, post.id) != content_hash do if Keyword.get(opts, :refresh_index, true) and
snapshot_content_hash(post.project_id, post.id) != content_hash do
:ok = rebuild_snapshot(post.project_id) :ok = rebuild_snapshot(post.project_id)
end end
@@ -193,8 +199,11 @@ defmodule BDS.Embeddings do
def remove_post(post_id) when is_binary(post_id) do def remove_post(post_id) when is_binary(post_id) do
project_id = project_id =
case Repo.get_by(Key, post_id: post_id) do case Repo.get_by(Key, post_id: post_id) do
%Key{project_id: project_id} -> project_id %Key{project_id: project_id} ->
nil -> case Repo.get(Post, post_id) do project_id
nil ->
case Repo.get(Post, post_id) do
%Post{project_id: project_id} -> project_id %Post{project_id: project_id} -> project_id
nil -> nil nil -> nil
end end
@@ -212,14 +221,20 @@ defmodule BDS.Embeddings do
def index_unindexed(project_id) when is_binary(project_id) do def index_unindexed(project_id) when is_binary(project_id) do
if enabled_for_project?(project_id) do if enabled_for_project?(project_id) do
posts = posts =
Repo.all(from post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at, asc: post.slug]) Repo.all(
from post in Post,
where: post.project_id == ^project_id,
order_by: [asc: post.created_at, asc: post.slug]
)
Enum.each(posts, fn post -> Enum.each(posts, fn post ->
body = resolve_post_body(post) body = resolve_post_body(post)
content_hash = hash_text(compose_embedding_source(post.title, body)) content_hash = hash_text(compose_embedding_source(post.title, body))
case Repo.get_by(Key, post_id: post.id, project_id: project_id) do case Repo.get_by(Key, post_id: post.id, project_id: project_id) do
%Key{content_hash: ^content_hash} -> :ok %Key{content_hash: ^content_hash} ->
:ok
_other -> _other ->
:ok = :ok =
sync_post_if_enabled( sync_post_if_enabled(
@@ -231,7 +246,8 @@ defmodule BDS.Embeddings do
:ok = rebuild_snapshot(project_id) :ok = rebuild_snapshot(project_id)
indexed = Repo.all(from key in Key, where: key.project_id == ^project_id, select: key.post_id) indexed =
Repo.all(from key in Key, where: key.project_id == ^project_id, select: key.post_id)
{:ok, indexed} {:ok, indexed}
else else
@@ -241,15 +257,29 @@ defmodule BDS.Embeddings do
def find_similar(post_id, limit \\ 5) when is_binary(post_id) and is_integer(limit) do def find_similar(post_id, limit \\ 5) when is_binary(post_id) and is_integer(limit) do
case source_post_and_vector(post_id) do case source_post_and_vector(post_id) do
{:disabled, _project_id} -> {:ok, []} {:disabled, _project_id} ->
{:error, :not_found} -> {:ok, []} {:ok, []}
{:error, :not_found} ->
{:ok, []}
{:ok, post, source_vector} -> {:ok, post, source_vector} ->
similar = similar =
case Index.neighbors(post.project_id, post.id, limit) do case Index.neighbors(post.project_id, post.id, limit) do
{:ok, neighbors} -> neighbors {:ok, neighbors} ->
neighbors
{:error, :missing} -> {:error, :missing} ->
Repo.all(from key in Key, where: key.project_id == ^post.project_id and key.post_id != ^post.id) Repo.all(
|> Enum.map(fn key -> %{post_id: key.post_id, score: cosine_similarity(source_vector, decode_vector(key.vector))} end) from key in Key,
where: key.project_id == ^post.project_id and key.post_id != ^post.id
)
|> Enum.map(fn key ->
%{
post_id: key.post_id,
score: cosine_similarity(source_vector, decode_vector(key.vector))
}
end)
|> Enum.sort_by(& &1.score, :desc) |> Enum.sort_by(& &1.score, :desc)
|> Enum.take(max(limit, 0)) |> Enum.take(max(limit, 0))
end end
@@ -261,18 +291,29 @@ defmodule BDS.Embeddings do
def compute_similarities(source_post_id, target_post_ids) def compute_similarities(source_post_id, target_post_ids)
when is_binary(source_post_id) and is_list(target_post_ids) do when is_binary(source_post_id) and is_list(target_post_ids) do
case source_post_and_vector(source_post_id) do case source_post_and_vector(source_post_id) do
{:disabled, _project_id} -> {:ok, %{}} {:disabled, _project_id} ->
{:error, :not_found} -> {:ok, %{}} {:ok, %{}}
{:error, :not_found} ->
{:ok, %{}}
{:ok, post, source_vector} -> {:ok, post, source_vector} ->
target_ids = Enum.uniq(target_post_ids) target_ids = Enum.uniq(target_post_ids)
scores = scores =
Repo.all(from key in Key, where: key.project_id == ^post.project_id and key.post_id in ^target_ids) Repo.all(
from key in Key,
where: key.project_id == ^post.project_id and key.post_id in ^target_ids
)
|> Enum.reduce(%{}, fn key, acc -> |> Enum.reduce(%{}, fn key, acc ->
if key.post_id == source_post_id do if key.post_id == source_post_id do
acc acc
else else
Map.put(acc, key.post_id, cosine_similarity(source_vector, decode_vector(key.vector))) Map.put(
acc,
key.post_id,
cosine_similarity(source_vector, decode_vector(key.vector))
)
end end
end) end)
@@ -289,7 +330,9 @@ defmodule BDS.Embeddings do
|> then(fn posts_by_id -> |> then(fn posts_by_id ->
Enum.reduce(similar, %{}, fn %{post_id: similar_post_id, score: score}, acc -> Enum.reduce(similar, %{}, fn %{post_id: similar_post_id, score: score}, acc ->
case Map.get(posts_by_id, similar_post_id) do case Map.get(posts_by_id, similar_post_id) do
nil -> acc nil ->
acc
similar_post -> similar_post ->
Enum.reduce(similar_post.tags || [], acc, fn tag, tag_acc -> Enum.reduce(similar_post.tags || [], acc, fn tag, tag_acc ->
Map.update(tag_acc, tag, score, &(&1 + score)) Map.update(tag_acc, tag, score, &(&1 + score))
@@ -320,7 +363,13 @@ defmodule BDS.Embeddings do
|> enrich_duplicate_pairs(project_id) |> enrich_duplicate_pairs(project_id)
{:error, :missing} -> {:error, :missing} ->
keys = Repo.all(from key in Key, where: key.project_id == ^project_id, order_by: [asc: key.post_id]) keys =
Repo.all(
from key in Key,
where: key.project_id == ^project_id,
order_by: [asc: key.post_id]
)
total_keys = length(keys) total_keys = length(keys)
:ok = report_rebuild_started(on_progress, total_keys, "embedding entries") :ok = report_rebuild_started(on_progress, total_keys, "embedding entries")
@@ -333,7 +382,8 @@ defmodule BDS.Embeddings do
for right <- keys, for right <- keys,
left.post_id < right.post_id, left.post_id < right.post_id,
pair_key(left.post_id, right.post_id) not in dismissed, pair_key(left.post_id, right.post_id) not in dismissed,
similarity = cosine_similarity(decode_vector(left.vector), decode_vector(right.vector)), similarity =
cosine_similarity(decode_vector(left.vector), decode_vector(right.vector)),
similarity >= @duplicate_threshold do similarity >= @duplicate_threshold do
%{ %{
post_id_a: left.post_id, post_id_a: left.post_id,
@@ -438,7 +488,9 @@ defmodule BDS.Embeddings do
|> Enum.flat_map(&[&1.post_id_a, &1.post_id_b]) |> Enum.flat_map(&[&1.post_id_a, &1.post_id_b])
|> Enum.uniq() |> Enum.uniq()
|> then(fn post_ids -> |> then(fn post_ids ->
Repo.all(from post in Post, where: post.project_id == ^project_id and post.id in ^post_ids) Repo.all(
from post in Post, where: post.project_id == ^project_id and post.id in ^post_ids
)
|> Map.new(&{&1.id, &1}) |> Map.new(&{&1.id, &1})
end) end)
@@ -454,7 +506,9 @@ defmodule BDS.Embeddings do
|> Map.put(:similarity, pair.score) |> Map.put(:similarity, pair.score)
|> Map.put(:exact_match, exact_match) |> Map.put(:exact_match, exact_match)
end) end)
|> Enum.sort_by(fn pair -> {not pair.exact_match, -pair.score, pair.post_id_a, pair.post_id_b} end) |> Enum.sort_by(fn pair ->
{not pair.exact_match, -pair.score, pair.post_id_a, pair.post_id_b}
end)
end end
defp exact_duplicate_match?(score, %Post{} = post_a, %Post{} = post_b) do defp exact_duplicate_match?(score, %Post{} = post_a, %Post{} = post_b) do
@@ -485,7 +539,8 @@ defmodule BDS.Embeddings do
end end
end end
defp resolve_post_body(%Post{content: content}) when is_binary(content) and content != "", do: content defp resolve_post_body(%Post{content: content}) when is_binary(content) and content != "",
do: content
defp resolve_post_body(%Post{project_id: project_id, file_path: file_path}) do defp resolve_post_body(%Post{project_id: project_id, file_path: file_path}) do
if file_path in [nil, ""] do if file_path in [nil, ""] do
@@ -507,7 +562,8 @@ defmodule BDS.Embeddings do
end end
end end
defp compose_embedding_source(title, content), do: string_or_empty(title) <> "\n\n" <> string_or_empty(content) defp compose_embedding_source(title, content),
do: string_or_empty(title) <> "\n\n" <> string_or_empty(content)
defp string_or_empty(nil), do: "" defp string_or_empty(nil), do: ""
defp string_or_empty(value) when is_binary(value), do: value defp string_or_empty(value) when is_binary(value), do: value
@@ -525,39 +581,27 @@ defmodule BDS.Embeddings do
Index.rebuild(project_id, model_id: model_id(), dimensions: dimensions()) Index.rebuild(project_id, model_id: model_id(), dimensions: dimensions())
end end
defp progress_callback(opts) do defp progress_callback(opts), do: ProgressReporter.callback(opts)
case Keyword.get(opts, :on_progress) do
callback when is_function(callback, 2) -> callback
_other -> nil
end
end
defp report_rebuild_started(nil, _total, _label), do: :ok
defp report_rebuild_started(callback, 0, label) do
callback.(1.0, "No #{label} to rebuild")
:ok
end
defp report_rebuild_started(callback, total, label) do defp report_rebuild_started(callback, total, label) do
callback.(0.0, "Rebuilding 0/#{total} #{label}") ProgressReporter.report_count_started(callback, total, label,
:ok verb: "Rebuilding",
start_progress: 0.0,
empty_suffix: "to rebuild",
message_style: :prefix_count
)
end end
defp report_rebuild_progress(nil, _current, _total, _label), do: :ok
defp report_rebuild_progress(_callback, _current, 0, _label), do: :ok
defp report_rebuild_progress(callback, current, total, label) do defp report_rebuild_progress(callback, current, total, label) do
callback.(current / total, "Rebuilding #{current}/#{total} #{label}") ProgressReporter.report_count_progress(callback, current, total, label,
:ok verb: "Rebuilding",
start_progress: 0.0,
message_style: :prefix_count
)
end end
defp report_rebuild_phase(nil, _value, _label), do: :ok defp report_rebuild_phase(callback, value, label),
do: ProgressReporter.report_phase(callback, value, label)
defp report_rebuild_phase(callback, value, label) do
callback.(value, label)
:ok
end
defp snapshot_content_hash(project_id, post_id) do defp snapshot_content_hash(project_id, post_id) do
case Index.read(project_id) do case Index.read(project_id) do

View File

@@ -6,6 +6,7 @@ defmodule BDS.Embeddings.Index do
alias BDS.Persistence alias BDS.Persistence
alias BDS.Embeddings.Key alias BDS.Embeddings.Key
alias BDS.Projects alias BDS.Projects
alias BDS.ProgressReporter
alias BDS.Repo alias BDS.Repo
@neighbor_limit 21 @neighbor_limit 21
@@ -206,30 +207,22 @@ defmodule BDS.Embeddings.Index do
defp sort_pair(post_id_a, post_id_b) when post_id_a <= post_id_b, do: {post_id_a, post_id_b} defp sort_pair(post_id_a, post_id_b) when post_id_a <= post_id_b, do: {post_id_a, post_id_b}
defp sort_pair(post_id_a, post_id_b), do: {post_id_b, post_id_a} defp sort_pair(post_id_a, post_id_b), do: {post_id_b, post_id_a}
defp progress_callback(opts) do defp progress_callback(opts), do: ProgressReporter.callback(opts)
case Keyword.get(opts, :on_progress) do
callback when is_function(callback, 2) -> callback
_other -> nil
end
end
defp report_scan_started(nil, _total, _label), do: :ok
defp report_scan_started(callback, 0, label) do
callback.(1.0, "No #{label} to scan")
:ok
end
defp report_scan_started(callback, total, label) do defp report_scan_started(callback, total, label) do
callback.(0.0, "Scanning 0/#{total} #{label}") ProgressReporter.report_count_started(callback, total, label,
:ok verb: "Scanning",
start_progress: 0.0,
empty_suffix: "to scan",
message_style: :prefix_count
)
end end
defp report_scan_progress(nil, _current, _total, _label), do: :ok
defp report_scan_progress(_callback, _current, 0, _label), do: :ok
defp report_scan_progress(callback, current, total, label) do defp report_scan_progress(callback, current, total, label) do
callback.(current / total, "Scanning #{current}/#{total} #{label}") ProgressReporter.report_count_progress(callback, current, total, label,
:ok verb: "Scanning",
start_progress: 0.0,
message_style: :prefix_count
)
end end
end end

View File

@@ -9,73 +9,61 @@ defmodule BDS.Generation.Progress do
@doc "Extract the `:on_progress` callback from a keyword list of options." @doc "Extract the `:on_progress` callback from a keyword list of options."
@spec callback(keyword()) :: callback() @spec callback(keyword()) :: callback()
def callback(opts) do def callback(opts), do: BDS.ProgressReporter.callback(opts)
case Keyword.get(opts, :on_progress) do
cb when is_function(cb, 2) -> cb
_other -> nil
end
end
@spec report_generation_started(callback(), non_neg_integer(), String.t()) :: :ok @spec report_generation_started(callback(), non_neg_integer(), String.t()) :: :ok
def report_generation_started(nil, _total, _label), do: :ok
def report_generation_started(callback, 0, label) do
callback.(1.0, "No #{label} to process")
:ok
end
def report_generation_started(callback, total, label) do def report_generation_started(callback, total, label) do
callback.(0.0, "Processing 0/#{total} #{label}") BDS.ProgressReporter.report_count_started(callback, total, label,
:ok verb: "Processing",
start_progress: 0.0,
empty_suffix: "to process",
message_style: :prefix_count
)
end end
@spec report_generation_progress(callback(), non_neg_integer(), non_neg_integer(), String.t()) :: :ok @spec report_generation_progress(callback(), non_neg_integer(), non_neg_integer(), String.t()) ::
def report_generation_progress(nil, _current, _total, _label), do: :ok
def report_generation_progress(_callback, _current, 0, _label), do: :ok
def report_generation_progress(callback, current, total, label) do
callback.(current / total, "Processing #{current}/#{total} #{label}")
:ok :ok
def report_generation_progress(callback, current, total, label) do
BDS.ProgressReporter.report_count_progress(callback, current, total, label,
verb: "Processing",
start_progress: 0.0,
message_style: :prefix_count
)
end end
@spec report_validation_progress(callback(), float(), String.t()) :: :ok @spec report_validation_progress(callback(), float(), String.t()) :: :ok
def report_validation_progress(nil, _progress, _message), do: :ok def report_validation_progress(callback, progress, message),
do: BDS.ProgressReporter.report_phase(callback, progress, message)
def report_validation_progress(callback, progress, message) do @spec report_validation_snapshot_progress(callback(), atom(), non_neg_integer(), integer()) ::
callback.(progress, message)
:ok :ok
end
@spec report_validation_snapshot_progress(callback(), atom(), non_neg_integer(), integer()) :: :ok
def report_validation_snapshot_progress(nil, _stage, _current, _total), do: :ok
def report_validation_snapshot_progress(_callback, _stage, _current, total)
when total <= 0,
do: :ok
def report_validation_snapshot_progress(callback, :posts, current, total) do def report_validation_snapshot_progress(callback, :posts, current, total) do
progress = min(0.18, current / total * 0.18) BDS.ProgressReporter.report_count_progress(callback, current, total, "sitemap URLs...",
callback.(progress, "Collecting sitemap URLs... #{current}/#{total}") verb: "Collecting",
:ok range: {0.0, 0.18},
message_style: :verb_label_count
)
end end
def report_validation_snapshot_progress(callback, :translations, current, total) do def report_validation_snapshot_progress(callback, :translations, current, total) do
progress = 0.18 + min(0.12, current / total * 0.12) BDS.ProgressReporter.report_count_progress(callback, current, total, "sitemap URLs...",
callback.(progress, "Collecting sitemap URLs... #{current}/#{total}") verb: "Collecting",
:ok range: {0.18, 0.30},
message_style: :verb_label_count
)
end end
@spec report_validation_collection_progress(callback(), non_neg_integer(), integer()) :: :ok @spec report_validation_collection_progress(callback(), non_neg_integer(), integer()) :: :ok
def report_validation_collection_progress(nil, _current, _total), do: :ok
def report_validation_collection_progress(_callback, _current, total) when total <= 0, do: :ok
def report_validation_collection_progress(callback, current, total) do def report_validation_collection_progress(callback, current, total) do
progress = min(0.49, 0.30 + current / total * 0.19) BDS.ProgressReporter.report_count_progress(callback, current, total, "sitemap URLs...",
callback.(progress, "Collecting sitemap URLs... #{current}/#{total}") verb: "Collecting",
:ok range: {0.30, 0.49},
message_style: :verb_label_count
)
end end
@spec report_snapshot_stage_progress(stage_callback(), atom(), non_neg_integer(), integer()) :: :ok @spec report_snapshot_stage_progress(stage_callback(), atom(), non_neg_integer(), integer()) ::
:ok
def report_snapshot_stage_progress(nil, _stage, _current, _total), do: :ok def report_snapshot_stage_progress(nil, _stage, _current, _total), do: :ok
def report_snapshot_stage_progress(_callback, _stage, _current, total) when total <= 0, do: :ok def report_snapshot_stage_progress(_callback, _stage, _current, total) when total <= 0, do: :ok
@@ -85,12 +73,15 @@ defmodule BDS.Generation.Progress do
end end
@spec report_validation_compare_progress(callback(), non_neg_integer(), integer()) :: :ok @spec report_validation_compare_progress(callback(), non_neg_integer(), integer()) :: :ok
def report_validation_compare_progress(nil, _current, _total), do: :ok
def report_validation_compare_progress(_callback, _current, total) when total <= 0, do: :ok
def report_validation_compare_progress(callback, current, total) do def report_validation_compare_progress(callback, current, total) do
progress = min(0.99, 0.5 + current / total * 0.49) BDS.ProgressReporter.report_count_progress(
callback.(progress, "Comparing sitemap to html pages... #{current}/#{total}") callback,
:ok current,
total,
"sitemap to html pages...",
verb: "Comparing",
range: {0.5, 0.99},
message_style: :verb_label_count
)
end end
end end

View File

@@ -1,45 +1,31 @@
defmodule BDS.Maintenance.Progress do defmodule BDS.Maintenance.Progress do
@moduledoc false @moduledoc false
def progress_callback(opts) do def progress_callback(opts), do: BDS.ProgressReporter.callback(opts)
case Keyword.get(opts, :on_progress) do
callback when is_function(callback, 2) -> callback
_other -> nil
end
end
def report_metadata_diff_phase(nil, _current, _total, _label), do: :ok
def report_metadata_diff_phase(callback, current, total, label) do def report_metadata_diff_phase(callback, current, total, label) do
value = if total <= 1, do: 0.0, else: (current - 1) / total progress = if total <= 1, do: 0.0, else: (current - 1) / total
callback.(value, "#{label} (#{current}/#{total})") BDS.ProgressReporter.report_phase(callback, progress, "#{label} (#{current}/#{total})")
:ok
end end
def report_metadata_diff_complete(nil), do: :ok
def report_metadata_diff_complete(callback) do def report_metadata_diff_complete(callback) do
callback.(1.0, "Metadata diff complete") BDS.ProgressReporter.report_phase(callback, 1.0, "Metadata diff complete")
:ok
end
def report_started(nil, _total, _label), do: :ok
def report_started(callback, 0, label) do
callback.(1.0, label)
:ok
end end
def report_started(callback, total, label) do def report_started(callback, total, label) do
callback.(0.05, "#{label} (0/#{total})") BDS.ProgressReporter.report_count_started(callback, total, label,
:ok verb: nil,
start_progress: 0.05,
empty_message: label,
message_style: :label
)
end end
def report_progress(nil, _current, _total, _label), do: :ok
def report_progress(_callback, _current, 0, _label), do: :ok
def report_progress(callback, current, total, label) do def report_progress(callback, current, total, label) do
callback.(0.05 + 0.95 * (current / total), "#{label} (#{current}/#{total})") BDS.ProgressReporter.report_count_progress(callback, current, total, label,
:ok verb: nil,
start_progress: 0.05,
message_style: :label
)
end end
end end

24
lib/bds/map_utils.ex Normal file
View File

@@ -0,0 +1,24 @@
defmodule BDS.MapUtils do
@moduledoc false
@typedoc "An attribute map that may use atom or string keys."
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@spec attr(attrs(), atom()) :: term()
def attr(attrs, key) 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 -> nil
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)
@spec blank_to_nil(term()) :: term()
def blank_to_nil(nil), do: nil
def blank_to_nil(""), do: nil
def blank_to_nil(value), do: value
end

View File

@@ -22,8 +22,7 @@ defmodule BDS.MCP.Util do
def normalize_term(value), do: value |> to_string() |> String.downcase() def normalize_term(value), do: value |> to_string() |> String.downcase()
@spec maybe_put(map(), term(), term()) :: map() @spec maybe_put(map(), term(), term()) :: map()
def maybe_put(map, _key, nil), do: map def maybe_put(map, key, value), do: BDS.MapUtils.maybe_put(map, key, value)
def maybe_put(map, key, value), do: Map.put(map, key, value)
@spec map_get(map(), atom(), term()) :: term() @spec map_get(map(), atom(), term()) :: term()
def map_get(map, key, default \\ nil) do def map_get(map, key, default \\ nil) do

View File

@@ -2,28 +2,12 @@ defmodule BDS.Media.FileOps do
@moduledoc false @moduledoc false
alias BDS.Persistence alias BDS.Persistence
alias BDS.ProgressReporter
alias BDS.Projects alias BDS.Projects
@typedoc "An attribute map that may use atom or string keys." defdelegate attr(attrs, key), to: BDS.MapUtils
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} defdelegate maybe_put(map, key, value), to: BDS.MapUtils
defdelegate blank_to_nil(value), to: BDS.MapUtils
@spec attr(attrs(), atom()) :: term()
def attr(attrs, key) 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 -> nil
end
end
@spec maybe_put(map(), atom(), term()) :: map()
def maybe_put(map, _key, nil), do: map
def maybe_put(map, key, value), do: Map.put(map, key, value)
@spec blank_to_nil(term()) :: term()
def blank_to_nil(nil), do: nil
def blank_to_nil(""), do: nil
def blank_to_nil(value), do: value
@spec atomic_write(Path.t(), iodata()) :: :ok | {:error, term()} @spec atomic_write(Path.t(), iodata()) :: :ok | {:error, term()}
def atomic_write(path, contents), do: Persistence.atomic_write(path, contents) def atomic_write(path, contents), do: Persistence.atomic_write(path, contents)
@@ -95,36 +79,20 @@ defmodule BDS.Media.FileOps do
def image_mime?(mime_type), do: String.starts_with?(mime_type || "", "image/") def image_mime?(mime_type), do: String.starts_with?(mime_type || "", "image/")
@spec progress_callback(keyword()) :: (float(), String.t() -> any()) | nil @spec progress_callback(keyword()) :: (float(), String.t() -> any()) | nil
def progress_callback(opts) do def progress_callback(opts), do: ProgressReporter.callback(opts)
case Keyword.get(opts, :on_progress) do
callback when is_function(callback, 2) -> callback
_other -> nil
end
end
@spec scaled_progress_reporter((float(), String.t() -> any()) | nil, float(), float()) :: @spec scaled_progress_reporter((float(), String.t() -> any()) | nil, float(), float()) ::
(float(), String.t() -> any()) | nil (float(), String.t() -> any()) | nil
def scaled_progress_reporter(nil, _start_value, _end_value), do: nil def scaled_progress_reporter(report, start_value, end_value),
do: ProgressReporter.scaled(report, start_value, end_value)
def scaled_progress_reporter(report, start_value, end_value) when is_function(report, 2) do @spec report_rebuild_started(
fn value, message -> (float(), String.t() -> any()) | nil,
scaled_value = start_value + (end_value - start_value) * value non_neg_integer(),
report.(scaled_value, message) String.t()
end ) :: :ok
end def report_rebuild_started(callback, total, label),
do: ProgressReporter.report_rebuild_started(callback, total, label)
@spec report_rebuild_started((float(), String.t() -> any()) | nil, non_neg_integer(), String.t()) :: :ok
def report_rebuild_started(nil, _total, _label), do: :ok
def report_rebuild_started(callback, 0, label) do
callback.(1.0, "No #{label} found")
:ok
end
def report_rebuild_started(callback, total, label) do
callback.(0.05, "Rebuilding #{label} (0/#{total})")
:ok
end
@spec report_rebuild_progress( @spec report_rebuild_progress(
(float(), String.t() -> any()) | nil, (float(), String.t() -> any()) | nil,
@@ -132,19 +100,10 @@ defmodule BDS.Media.FileOps do
non_neg_integer(), non_neg_integer(),
String.t() String.t()
) :: :ok ) :: :ok
def report_rebuild_progress(nil, _current, _total, _label), do: :ok def report_rebuild_progress(callback, current, total, label),
def report_rebuild_progress(_callback, _current, 0, _label), do: :ok do: ProgressReporter.report_rebuild_progress(callback, current, total, label)
def report_rebuild_progress(callback, current, total, label) do
callback.(0.05 + 0.95 * (current / total), "Rebuilding #{label} (#{current}/#{total})")
:ok
end
@spec report_rebuild_phase((float(), String.t() -> any()) | nil, float(), String.t()) :: :ok @spec report_rebuild_phase((float(), String.t() -> any()) | nil, float(), String.t()) :: :ok
def report_rebuild_phase(nil, _progress, _message), do: :ok def report_rebuild_phase(callback, progress, message),
do: ProgressReporter.report_phase(callback, progress, message)
def report_rebuild_phase(callback, progress, message) do
callback.(progress, message)
:ok
end
end end

View File

@@ -2,6 +2,7 @@ defmodule BDS.Posts do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
alias BDS.Embeddings alias BDS.Embeddings
alias BDS.Media alias BDS.Media
@@ -193,7 +194,8 @@ defmodule BDS.Posts do
end end
end end
@spec rebuild_posts_from_files(String.t(), rebuild_opts()) :: {:ok, [Post.t()]} | {:error, term()} @spec rebuild_posts_from_files(String.t(), rebuild_opts()) ::
{:ok, [Post.t()]} | {:error, term()}
defdelegate rebuild_posts_from_files(project_id, opts \\ []), to: RebuildFromFiles defdelegate rebuild_posts_from_files(project_id, opts \\ []), to: RebuildFromFiles
@spec discard_post_changes(String.t()) :: @spec discard_post_changes(String.t()) ::
@@ -211,7 +213,8 @@ defmodule BDS.Posts do
full_path = Path.join(Projects.project_data_dir(project), post.file_path) full_path = Path.join(Projects.project_data_dir(project), post.file_path)
if File.exists?(full_path) do if File.exists?(full_path) do
with {:ok, restored_post} <- RebuildFromFiles.upsert_post_from_file(post.project_id, project, full_path) do with {:ok, restored_post} <-
RebuildFromFiles.upsert_post_from_file(post.project_id, project, full_path) do
:ok = PostLinks.sync_post_links(restored_post) :ok = PostLinks.sync_post_links(restored_post)
{:ok, restored_post} {:ok, restored_post}
else else
@@ -262,7 +265,8 @@ defmodule BDS.Posts do
full_path = Path.join(Projects.project_data_dir(project), post.file_path) full_path = Path.join(Projects.project_data_dir(project), post.file_path)
if File.exists?(full_path) do if File.exists?(full_path) do
with {:ok, repaired_post} <- RebuildFromFiles.upsert_post_from_file(post.project_id, project, full_path) do with {:ok, repaired_post} <-
RebuildFromFiles.upsert_post_from_file(post.project_id, project, full_path) do
:ok = PostLinks.sync_post_links(repaired_post) :ok = PostLinks.sync_post_links(repaired_post)
{:ok, repaired_post} {:ok, repaired_post}
else else
@@ -405,7 +409,9 @@ defmodule BDS.Posts do
@spec rebuild_post_links(String.t(), rebuild_opts()) :: :ok @spec rebuild_post_links(String.t(), rebuild_opts()) :: :ok
def rebuild_post_links(project_id, opts \\ []) do def rebuild_post_links(project_id, opts \\ []) do
post_ids = Repo.all(from(post in Post, where: post.project_id == ^project_id, select: post.id)) post_ids =
Repo.all(from(post in Post, where: post.project_id == ^project_id, select: post.id))
on_progress = RebuildFromFiles.progress_callback(opts) on_progress = RebuildFromFiles.progress_callback(opts)
Repo.delete_all( Repo.delete_all(
@@ -414,7 +420,14 @@ defmodule BDS.Posts do
) )
) )
posts = Repo.all(from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at])) posts =
Repo.all(
from(post in Post,
where: post.project_id == ^project_id,
order_by: [asc: post.created_at]
)
)
total_posts = length(posts) total_posts = length(posts)
:ok = RebuildFromFiles.report_rebuild_started(on_progress, total_posts, "post links") :ok = RebuildFromFiles.report_rebuild_started(on_progress, total_posts, "post links")
@@ -422,7 +435,9 @@ defmodule BDS.Posts do
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.each(fn {post, index} -> |> Enum.each(fn {post, index} ->
PostLinks.sync_post_links(post) PostLinks.sync_post_links(post)
:ok = RebuildFromFiles.report_rebuild_progress(on_progress, index, total_posts, "post links")
:ok =
RebuildFromFiles.report_rebuild_progress(on_progress, index, total_posts, "post links")
end) end)
:ok :ok
@@ -438,8 +453,11 @@ defmodule BDS.Posts do
@spec delete_post_translation(String.t()) :: {:ok, :deleted} | {:error, :not_found} @spec delete_post_translation(String.t()) :: {:ok, :deleted} | {:error, :not_found}
defdelegate delete_post_translation(translation_id), to: Translations defdelegate delete_post_translation(translation_id), to: Translations
@spec validate_translations(String.t(), rebuild_opts()) :: {:ok, translation_validation_report()} @spec validate_translations(String.t(), rebuild_opts()) ::
defdelegate validate_translations(project_id, opts \\ []), to: TranslationValidation, as: :validate {:ok, translation_validation_report()}
defdelegate validate_translations(project_id, opts \\ []),
to: TranslationValidation,
as: :validate
@spec fix_invalid_translations(map()) :: @spec fix_invalid_translations(map()) ::
{:ok, {:ok,
@@ -458,6 +476,7 @@ defmodule BDS.Posts do
project = Projects.get_project!(post.project_id) project = Projects.get_project!(post.project_id)
full_path = Path.join(Projects.project_data_dir(project), post.file_path) full_path = Path.join(Projects.project_data_dir(project), post.file_path)
body = published_post_body(post, full_path) body = published_post_body(post, full_path)
:ok = :ok =
Persistence.atomic_write( Persistence.atomic_write(
full_path, full_path,
@@ -544,9 +563,6 @@ defmodule BDS.Posts do
) )
end end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp normalize_title(nil), do: "" defp normalize_title(nil), do: ""
defp normalize_title(title), do: title defp normalize_title(title), do: title
@@ -564,12 +580,4 @@ defmodule BDS.Posts do
defp has_attr?(attrs, key) do defp has_attr?(attrs, key) do
Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key))
end end
defp attr(attrs, key) 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 -> nil
end
end
end end

View File

@@ -5,6 +5,7 @@ defmodule BDS.Posts.RebuildFromFiles do
alias BDS.Embeddings alias BDS.Embeddings
alias BDS.Frontmatter alias BDS.Frontmatter
alias BDS.Persistence alias BDS.Persistence
alias BDS.ProgressReporter
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.Slugs alias BDS.Posts.Slugs
alias BDS.Posts.Translation alias BDS.Posts.Translation
@@ -113,7 +114,9 @@ defmodule BDS.Posts.RebuildFromFiles do
with {:ok, rebuild_file} <- parse_rebuild_file(project, full_path) do with {:ok, rebuild_file} <- parse_rebuild_file(project, full_path) do
if TranslationValidation.translation_rebuild_file?(rebuild_file) do if TranslationValidation.translation_rebuild_file?(rebuild_file) do
source_post_id = Map.fetch!(rebuild_file.fields, "translationFor") source_post_id = Map.fetch!(rebuild_file.fields, "translationFor")
language = TranslationValidation.normalize_language(Map.fetch!(rebuild_file.fields, "language"))
language =
TranslationValidation.normalize_language(Map.fetch!(rebuild_file.fields, "language"))
case Repo.get(Post, source_post_id) do case Repo.get(Post, source_post_id) do
nil -> nil ->
@@ -267,50 +270,21 @@ defmodule BDS.Posts.RebuildFromFiles do
def parse_translation_status(status), do: String.to_existing_atom(status) def parse_translation_status(status), do: String.to_existing_atom(status)
@doc false @doc false
def progress_callback(opts) do def progress_callback(opts), do: ProgressReporter.callback(opts)
case Keyword.get(opts, :on_progress) do
callback when is_function(callback, 2) -> callback
_other -> nil
end
end
@doc false @doc false
def report_rebuild_started(nil, _total, _label), do: :ok def report_rebuild_started(callback, total, label),
do: ProgressReporter.report_rebuild_started(callback, total, label)
def report_rebuild_started(callback, 0, label) do
callback.(1.0, "No #{label} found")
:ok
end
def report_rebuild_started(callback, total, label) do
callback.(0.05, "Rebuilding #{label} (0/#{total})")
:ok
end
@doc false @doc false
def report_rebuild_progress(nil, _current, _total, _label), do: :ok def report_rebuild_progress(callback, current, total, label),
def report_rebuild_progress(_callback, _current, 0, _label), do: :ok do: ProgressReporter.report_rebuild_progress(callback, current, total, label)
def report_rebuild_progress(callback, current, total, label) do defp scaled_progress_reporter(report, start_value, end_value),
callback.(0.05 + 0.95 * (current / total), "Rebuilding #{label} (#{current}/#{total})") do: ProgressReporter.scaled(report, start_value, end_value)
:ok
end
defp scaled_progress_reporter(nil, _start_value, _end_value), do: nil defp report_rebuild_phase(callback, progress, message),
do: ProgressReporter.report_phase(callback, progress, message)
defp scaled_progress_reporter(report, start_value, end_value) when is_function(report, 2) do
fn value, message ->
scaled_value = start_value + (end_value - start_value) * value
report.(scaled_value, message)
end
end
defp report_rebuild_phase(nil, _progress, _message), do: :ok
defp report_rebuild_phase(callback, progress, message) do
callback.(progress, message)
:ok
end
defp unique_post_id(nil), do: Ecto.UUID.generate() defp unique_post_id(nil), do: Ecto.UUID.generate()

View File

@@ -2,6 +2,7 @@ defmodule BDS.Posts.Translations do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
alias BDS.Persistence alias BDS.Persistence
alias BDS.Posts alias BDS.Posts
@@ -265,15 +266,4 @@ defmodule BDS.Posts.Translations do
|> String.split("-", parts: 2) |> String.split("-", parts: 2)
|> hd() |> hd()
end end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp attr(attrs, key) 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 -> nil
end
end
end end

View File

@@ -0,0 +1,116 @@
defmodule BDS.ProgressReporter do
@moduledoc false
@typedoc "A 2-arity progress callback `(progress :: float(), message :: String.t()) -> any()`."
@type callback :: (float(), String.t() -> any()) | nil
@type message_style :: :verb_label_parenthesized | :prefix_count | :verb_label_count | :label
@type count_opts :: [
{:verb, String.t() | nil},
{:start_progress, float()},
{:range, {float(), float()}},
{:empty_message, String.t()},
{:empty_suffix, String.t()},
{:message_style, message_style()}
]
@spec callback(keyword()) :: callback()
def callback(opts) do
case Keyword.get(opts, :on_progress) do
callback when is_function(callback, 2) -> callback
_other -> nil
end
end
@spec scaled(callback(), float(), float()) :: callback()
def scaled(nil, _start_value, _end_value), do: nil
def scaled(report, start_value, end_value) when is_function(report, 2) do
fn value, message ->
scaled_value = start_value + (end_value - start_value) * value
report.(scaled_value, message)
end
end
@spec report_count_started(callback(), non_neg_integer(), String.t(), count_opts()) :: :ok
def report_count_started(callback, total, label, opts \\ [])
def report_count_started(nil, _total, _label, _opts), do: :ok
def report_count_started(callback, 0, label, opts) do
callback.(1.0, empty_message(label, opts))
:ok
end
def report_count_started(callback, total, label, opts) do
callback.(start_progress(opts), count_message(0, total, label, opts))
:ok
end
@spec report_count_progress(
callback(),
non_neg_integer(),
non_neg_integer(),
String.t(),
count_opts()
) :: :ok
def report_count_progress(callback, current, total, label, opts \\ [])
def report_count_progress(nil, _current, _total, _label, _opts), do: :ok
def report_count_progress(_callback, _current, 0, _label, _opts), do: :ok
def report_count_progress(callback, current, total, label, opts) do
callback.(count_progress(current, total, opts), count_message(current, total, label, opts))
:ok
end
@spec report_rebuild_started(callback(), non_neg_integer(), String.t()) :: :ok
def report_rebuild_started(callback, total, label) do
report_count_started(callback, total, label,
verb: "Rebuilding",
start_progress: 0.05,
empty_suffix: "found",
message_style: :verb_label_parenthesized
)
end
@spec report_rebuild_progress(callback(), non_neg_integer(), non_neg_integer(), String.t()) ::
:ok
def report_rebuild_progress(callback, current, total, label) do
report_count_progress(callback, current, total, label,
verb: "Rebuilding",
start_progress: 0.05,
message_style: :verb_label_parenthesized
)
end
@spec report_phase(callback(), float(), String.t()) :: :ok
def report_phase(nil, _progress, _message), do: :ok
def report_phase(callback, progress, message) do
callback.(progress, message)
:ok
end
defp count_progress(current, total, opts) do
{start_value, end_value} = Keyword.get(opts, :range, {start_progress(opts), 1.0})
start_value + (end_value - start_value) * (current / total)
end
defp start_progress(opts), do: Keyword.get(opts, :start_progress, 0.0)
defp empty_message(label, opts) do
case Keyword.fetch(opts, :empty_message) do
{:ok, message} -> message
:error -> "No #{label} #{Keyword.get(opts, :empty_suffix, "found")}"
end
end
defp count_message(current, total, label, opts) do
case Keyword.get(opts, :message_style, :verb_label_parenthesized) do
:prefix_count -> "#{verb!(opts)} #{current}/#{total} #{label}"
:verb_label_count -> "#{verb!(opts)} #{label} #{current}/#{total}"
:label -> "#{label} (#{current}/#{total})"
:verb_label_parenthesized -> "#{verb!(opts)} #{label} (#{current}/#{total})"
end
end
defp verb!(opts), do: Keyword.get(opts, :verb, "Processing")
end

View File

@@ -3,6 +3,8 @@ defmodule BDS.Publishing do
use GenServer use GenServer
import BDS.MapUtils, only: [attr: 2]
alias BDS.Persistence alias BDS.Persistence
alias BDS.Publishing.PublishJob alias BDS.Publishing.PublishJob
alias BDS.Projects alias BDS.Projects
@@ -44,7 +46,9 @@ defmodule BDS.Publishing do
def handle_call({:update_job, job_id, attrs}, _from, state) do def handle_call({:update_job, job_id, attrs}, _from, state) do
reply = reply =
case Repo.get(PublishJob, job_id) do case Repo.get(PublishJob, job_id) do
nil -> :ok nil ->
:ok
job -> job ->
attrs = Map.put(attrs, :updated_at, Persistence.now_ms()) attrs = Map.put(attrs, :updated_at, Persistence.now_ms())
job |> PublishJob.changeset(attrs) |> Repo.update!() job |> PublishJob.changeset(attrs) |> Repo.update!()
@@ -335,12 +339,4 @@ defmodule BDS.Publishing do
defp normalize_ssh_mode(mode) when mode in [:scp, :rsync], do: mode defp normalize_ssh_mode(mode) when mode in [:scp, :rsync], do: mode
defp normalize_ssh_mode("rsync"), do: :rsync defp normalize_ssh_mode("rsync"), do: :rsync
defp normalize_ssh_mode(_mode), do: :scp defp normalize_ssh_mode(_mode), do: :scp
defp attr(attrs, key) 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 -> nil
end
end
end end

View File

@@ -2,10 +2,12 @@ defmodule BDS.Scripts do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
alias BDS.DocumentFields alias BDS.DocumentFields
alias BDS.Frontmatter alias BDS.Frontmatter
alias BDS.Persistence alias BDS.Persistence
alias BDS.ProgressReporter
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
alias BDS.Scripts.Script alias BDS.Scripts.Script
@@ -321,45 +323,15 @@ defmodule BDS.Scripts do
end end
end end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp has_attr?(attrs, key) do defp has_attr?(attrs, key) do
Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key))
end end
defp attr(attrs, key) do defp progress_callback(opts), do: ProgressReporter.callback(opts)
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 -> nil
end
end
defp progress_callback(opts) do defp report_rebuild_started(callback, total, label),
case Keyword.get(opts, :on_progress) do do: ProgressReporter.report_rebuild_started(callback, total, label)
callback when is_function(callback, 2) -> callback
_other -> nil
end
end
defp report_rebuild_started(nil, _total, _label), do: :ok defp report_rebuild_progress(callback, current, total, label),
do: ProgressReporter.report_rebuild_progress(callback, current, total, label)
defp report_rebuild_started(callback, 0, label) do
callback.(1.0, "No #{label} found")
:ok
end
defp report_rebuild_started(callback, total, label) do
callback.(0.05, "Rebuilding #{label} (0/#{total})")
:ok
end
defp report_rebuild_progress(nil, _current, _total, _label), do: :ok
defp report_rebuild_progress(_callback, _current, 0, _label), do: :ok
defp report_rebuild_progress(callback, current, total, label) do
callback.(0.05 + 0.95 * (current / total), "Rebuilding #{label} (#{current}/#{total})")
:ok
end
end end

View File

@@ -2,11 +2,13 @@ defmodule BDS.Search do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
import BDS.MapUtils, only: [attr: 2]
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.Media.Translation, as: MediaTranslation alias BDS.Media.Translation, as: MediaTranslation
alias BDS.Persistence alias BDS.Persistence
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.ProgressReporter
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
@@ -244,31 +246,23 @@ defmodule BDS.Search do
:ok :ok
end end
defp progress_callback(opts) do defp progress_callback(opts), do: ProgressReporter.callback(opts)
case Keyword.get(opts, :on_progress) do
callback when is_function(callback, 2) -> callback
_other -> nil
end
end
defp report_reindex_started(nil, _total, _label), do: :ok
defp report_reindex_started(callback, 0, label) do
callback.(1.0, "No #{label} to reindex")
:ok
end
defp report_reindex_started(callback, total, label) do defp report_reindex_started(callback, total, label) do
callback.(0.0, "Reindexing 0/#{total} #{label}") ProgressReporter.report_count_started(callback, total, label,
:ok verb: "Reindexing",
start_progress: 0.0,
empty_suffix: "to reindex",
message_style: :prefix_count
)
end end
defp report_reindex_progress(nil, _current, _total, _label), do: :ok
defp report_reindex_progress(_callback, _current, 0, _label), do: :ok
defp report_reindex_progress(callback, current, total, label) do defp report_reindex_progress(callback, current, total, label) do
callback.(current / total, "Reindexing #{current}/#{total} #{label}") ProgressReporter.report_count_progress(callback, current, total, label,
:ok verb: "Reindexing",
start_progress: 0.0,
message_style: :prefix_count
)
end end
defp insert_post_index(%Post{} = post) do defp insert_post_index(%Post{} = post) do
@@ -665,7 +659,9 @@ defmodule BDS.Search do
defp normalize_non_negative_integer(value, default), do: normalize_integer(value) || default defp normalize_non_negative_integer(value, default), do: normalize_integer(value) || default
defp normalize_timestamp(nil, _position), do: nil defp normalize_timestamp(nil, _position), do: nil
defp normalize_timestamp(value, _position) when is_integer(value), do: Persistence.normalize_unix_timestamp(value)
defp normalize_timestamp(value, _position) when is_integer(value),
do: Persistence.normalize_unix_timestamp(value)
defp normalize_timestamp(value, position) when is_binary(value) do defp normalize_timestamp(value, position) when is_binary(value) do
case Date.from_iso8601(value) do case Date.from_iso8601(value) do
@@ -680,12 +676,4 @@ defmodule BDS.Search do
end end
defp blank_query?(query), do: query in [nil, ""] or String.trim(to_string(query)) == "" defp blank_query?(query), do: query in [nil, ""] or String.trim(to_string(query)) == ""
defp attr(attrs, key) 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 -> nil
end
end
end end

View File

@@ -2,10 +2,12 @@ defmodule BDS.Templates do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
alias BDS.DocumentFields alias BDS.DocumentFields
alias BDS.Frontmatter alias BDS.Frontmatter
alias BDS.Persistence alias BDS.Persistence
alias BDS.ProgressReporter
alias BDS.Posts alias BDS.Posts
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
@@ -522,45 +524,15 @@ defmodule BDS.Templates do
end end
end end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp has_attr?(attrs, key) do defp has_attr?(attrs, key) do
Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key))
end end
defp attr(attrs, key) do defp progress_callback(opts), do: ProgressReporter.callback(opts)
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 -> nil
end
end
defp progress_callback(opts) do defp report_rebuild_started(callback, total, label),
case Keyword.get(opts, :on_progress) do do: ProgressReporter.report_rebuild_started(callback, total, label)
callback when is_function(callback, 2) -> callback
_other -> nil
end
end
defp report_rebuild_started(nil, _total, _label), do: :ok defp report_rebuild_progress(callback, current, total, label),
do: ProgressReporter.report_rebuild_progress(callback, current, total, label)
defp report_rebuild_started(callback, 0, label) do
callback.(1.0, "No #{label} found")
:ok
end
defp report_rebuild_started(callback, total, label) do
callback.(0.05, "Rebuilding #{label} (0/#{total})")
:ok
end
defp report_rebuild_progress(nil, _current, _total, _label), do: :ok
defp report_rebuild_progress(_callback, _current, 0, _label), do: :ok
defp report_rebuild_progress(callback, current, total, label) do
callback.(0.05 + 0.95 * (current / total), "Rebuilding #{label} (#{current}/#{total})")
:ok
end
end end

View File

@@ -0,0 +1,31 @@
defmodule BDS.MapUtilsTest do
use ExUnit.Case, async: true
alias BDS.MapUtils
describe "attr/2" do
test "reads atom and string keys while preserving explicit nil" do
assert MapUtils.attr(%{title: "Atom title"}, :title) == "Atom title"
assert MapUtils.attr(%{"title" => "String title"}, :title) == "String title"
assert MapUtils.attr(%{"title" => "fallback", title: nil}, :title) == nil
assert MapUtils.attr(%{}, :title) == nil
end
end
describe "maybe_put/3" do
test "skips nil values and keeps other values" do
assert MapUtils.maybe_put(%{}, :title, nil) == %{}
assert MapUtils.maybe_put(%{}, :title, "") == %{title: ""}
assert MapUtils.maybe_put(%{}, :published, false) == %{published: false}
end
end
describe "blank_to_nil/1" do
test "normalizes nil and empty string only" do
assert MapUtils.blank_to_nil(nil) == nil
assert MapUtils.blank_to_nil("") == nil
assert MapUtils.blank_to_nil(" ") == " "
assert MapUtils.blank_to_nil(42) == 42
end
end
end

View File

@@ -0,0 +1,140 @@
defmodule BDS.ProgressReporterTest do
use ExUnit.Case, async: true
alias BDS.ProgressReporter
test "extracts only two-arity progress callbacks" do
callback = fn _progress, _message -> :ok end
assert ProgressReporter.callback(on_progress: callback) == callback
assert ProgressReporter.callback(on_progress: fn _progress -> :ok end) == nil
assert ProgressReporter.callback([]) == nil
end
test "reports rebuild start and progress messages" do
parent = self()
callback = fn progress, message -> send(parent, {progress, message}) end
assert ProgressReporter.report_rebuild_started(callback, 3, "post files") == :ok
assert ProgressReporter.report_rebuild_progress(callback, 2, 4, "post files") == :ok
assert_received {0.05, "Rebuilding post files (0/3)"}
assert_received {0.525, "Rebuilding post files (2/4)"}
end
test "reports counted progress with prefix count message style" do
parent = self()
callback = fn progress, message -> send(parent, {progress, message}) end
assert ProgressReporter.report_count_started(callback, 3, "pages",
verb: "Processing",
start_progress: 0.0,
empty_suffix: "to process",
message_style: :prefix_count
) == :ok
assert ProgressReporter.report_count_progress(callback, 2, 4, "pages",
verb: "Processing",
start_progress: 0.0,
message_style: :prefix_count
) == :ok
assert_progress_received(0.0, "Processing 0/3 pages")
assert_received {0.5, "Processing 2/4 pages"}
end
test "reports counted progress with label message style" do
parent = self()
callback = fn progress, message -> send(parent, {progress, message}) end
assert ProgressReporter.report_count_started(callback, 5, "Repairing metadata",
verb: nil,
start_progress: 0.05,
message_style: :label
) == :ok
assert ProgressReporter.report_count_progress(callback, 3, 5, "Repairing metadata",
verb: nil,
start_progress: 0.05,
message_style: :label
) == :ok
assert_received {0.05, "Repairing metadata (0/5)"}
assert_received {0.62, "Repairing metadata (3/5)"}
end
test "reports counted progress in a subrange" do
parent = self()
callback = fn progress, message -> send(parent, {progress, message}) end
assert ProgressReporter.report_count_progress(callback, 1, 2, "sitemap URLs",
verb: "Collecting",
range: {0.30, 0.49},
message_style: :prefix_count
) == :ok
assert_received {0.395, "Collecting 1/2 sitemap URLs"}
end
test "reports counted progress with verb label count message style" do
parent = self()
callback = fn progress, message -> send(parent, {progress, message}) end
assert ProgressReporter.report_count_progress(callback, 1, 2, "sitemap URLs...",
verb: "Collecting",
range: {0.30, 0.49},
message_style: :verb_label_count
) == :ok
assert_received {0.395, "Collecting sitemap URLs... 1/2"}
end
test "uses configurable empty counted messages" do
parent = self()
callback = fn progress, message -> send(parent, {progress, message}) end
assert ProgressReporter.report_count_started(callback, 0, "posts",
verb: "Reindexing",
empty_suffix: "to reindex",
message_style: :prefix_count
) == :ok
assert_received {1.0, "No posts to reindex"}
end
test "reports empty rebuilds as complete" do
parent = self()
callback = fn progress, message -> send(parent, {progress, message}) end
assert ProgressReporter.report_rebuild_started(callback, 0, "media files") == :ok
assert_received {1.0, "No media files found"}
end
test "ignores nil callbacks and zero totals" do
assert ProgressReporter.report_rebuild_started(nil, 3, "post files") == :ok
assert ProgressReporter.report_rebuild_progress(nil, 1, 3, "post files") == :ok
assert ProgressReporter.report_rebuild_progress(
fn _, _ -> flunk("should not call") end,
1,
0,
"post files"
) == :ok
end
test "scales nested progress reporters" do
parent = self()
callback = fn progress, message -> send(parent, {progress, message}) end
scaled = ProgressReporter.scaled(callback, 0.25, 0.75)
scaled.(0.5, "Halfway")
assert_received {0.5, "Halfway"}
end
defp assert_progress_received(expected_progress, expected_message) do
assert_received {progress, ^expected_message}
assert_in_delta progress, expected_progress, 0.000001
end
end