Compare commits
2 Commits
a95e9482a7
...
f6425de51d
| Author | SHA1 | Date | |
|---|---|---|---|
| f6425de51d | |||
| 79ee67c2e0 |
14
CODESMELL.md
14
CODESMELL.md
@@ -75,17 +75,9 @@ _None._ All modules previously on the queue have been split; refresh the queue i
|
||||
|
||||
## 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`):**
|
||||
|
||||
- `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.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -171,6 +163,8 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search`
|
||||
|
||||
### 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.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.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule BDS.Embeddings do
|
||||
alias BDS.Embeddings.Key
|
||||
alias BDS.Metadata
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.ProgressReporter
|
||||
alias BDS.Projects
|
||||
alias BDS.Repo
|
||||
|
||||
@@ -87,7 +88,11 @@ defmodule BDS.Embeddings do
|
||||
on_progress = progress_callback(opts)
|
||||
|
||||
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)
|
||||
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
|
||||
%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)
|
||||
end
|
||||
|
||||
@@ -193,8 +199,11 @@ defmodule BDS.Embeddings do
|
||||
def remove_post(post_id) when is_binary(post_id) do
|
||||
project_id =
|
||||
case Repo.get_by(Key, post_id: post_id) do
|
||||
%Key{project_id: project_id} -> project_id
|
||||
nil -> case Repo.get(Post, post_id) do
|
||||
%Key{project_id: project_id} ->
|
||||
project_id
|
||||
|
||||
nil ->
|
||||
case Repo.get(Post, post_id) do
|
||||
%Post{project_id: project_id} -> project_id
|
||||
nil -> nil
|
||||
end
|
||||
@@ -212,14 +221,20 @@ defmodule BDS.Embeddings do
|
||||
def index_unindexed(project_id) when is_binary(project_id) do
|
||||
if enabled_for_project?(project_id) do
|
||||
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 ->
|
||||
body = resolve_post_body(post)
|
||||
content_hash = hash_text(compose_embedding_source(post.title, body))
|
||||
|
||||
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 ->
|
||||
:ok =
|
||||
sync_post_if_enabled(
|
||||
@@ -231,7 +246,8 @@ defmodule BDS.Embeddings do
|
||||
|
||||
: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}
|
||||
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
|
||||
case source_post_and_vector(post_id) do
|
||||
{:disabled, _project_id} -> {:ok, []}
|
||||
{:error, :not_found} -> {:ok, []}
|
||||
{:disabled, _project_id} ->
|
||||
{:ok, []}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:ok, []}
|
||||
|
||||
{:ok, post, source_vector} ->
|
||||
similar =
|
||||
case Index.neighbors(post.project_id, post.id, limit) do
|
||||
{:ok, neighbors} -> neighbors
|
||||
{:ok, neighbors} ->
|
||||
neighbors
|
||||
|
||||
{:error, :missing} ->
|
||||
Repo.all(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)
|
||||
Repo.all(
|
||||
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.take(max(limit, 0))
|
||||
end
|
||||
@@ -261,18 +291,29 @@ defmodule BDS.Embeddings do
|
||||
def compute_similarities(source_post_id, target_post_ids)
|
||||
when is_binary(source_post_id) and is_list(target_post_ids) do
|
||||
case source_post_and_vector(source_post_id) do
|
||||
{:disabled, _project_id} -> {:ok, %{}}
|
||||
{:error, :not_found} -> {:ok, %{}}
|
||||
{:disabled, _project_id} ->
|
||||
{:ok, %{}}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:ok, %{}}
|
||||
|
||||
{:ok, post, source_vector} ->
|
||||
target_ids = Enum.uniq(target_post_ids)
|
||||
|
||||
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 ->
|
||||
if key.post_id == source_post_id do
|
||||
acc
|
||||
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)
|
||||
|
||||
@@ -289,7 +330,9 @@ defmodule BDS.Embeddings do
|
||||
|> then(fn posts_by_id ->
|
||||
Enum.reduce(similar, %{}, fn %{post_id: similar_post_id, score: score}, acc ->
|
||||
case Map.get(posts_by_id, similar_post_id) do
|
||||
nil -> acc
|
||||
nil ->
|
||||
acc
|
||||
|
||||
similar_post ->
|
||||
Enum.reduce(similar_post.tags || [], acc, fn tag, tag_acc ->
|
||||
Map.update(tag_acc, tag, score, &(&1 + score))
|
||||
@@ -320,7 +363,13 @@ defmodule BDS.Embeddings do
|
||||
|> enrich_duplicate_pairs(project_id)
|
||||
|
||||
{: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)
|
||||
|
||||
:ok = report_rebuild_started(on_progress, total_keys, "embedding entries")
|
||||
@@ -333,7 +382,8 @@ defmodule BDS.Embeddings do
|
||||
for right <- keys,
|
||||
left.post_id < right.post_id,
|
||||
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
|
||||
%{
|
||||
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.uniq()
|
||||
|> 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})
|
||||
end)
|
||||
|
||||
@@ -454,7 +506,9 @@ defmodule BDS.Embeddings do
|
||||
|> Map.put(:similarity, pair.score)
|
||||
|> Map.put(:exact_match, exact_match)
|
||||
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
|
||||
|
||||
defp exact_duplicate_match?(score, %Post{} = post_a, %Post{} = post_b) do
|
||||
@@ -485,7 +539,8 @@ defmodule BDS.Embeddings do
|
||||
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
|
||||
if file_path in [nil, ""] do
|
||||
@@ -507,7 +562,8 @@ defmodule BDS.Embeddings do
|
||||
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(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())
|
||||
end
|
||||
|
||||
defp progress_callback(opts) do
|
||||
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 progress_callback(opts), do: ProgressReporter.callback(opts)
|
||||
|
||||
defp report_rebuild_started(callback, total, label) do
|
||||
callback.(0.0, "Rebuilding 0/#{total} #{label}")
|
||||
:ok
|
||||
ProgressReporter.report_count_started(callback, total, label,
|
||||
verb: "Rebuilding",
|
||||
start_progress: 0.0,
|
||||
empty_suffix: "to rebuild",
|
||||
message_style: :prefix_count
|
||||
)
|
||||
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.(current / total, "Rebuilding #{current}/#{total} #{label}")
|
||||
:ok
|
||||
ProgressReporter.report_count_progress(callback, current, total, label,
|
||||
verb: "Rebuilding",
|
||||
start_progress: 0.0,
|
||||
message_style: :prefix_count
|
||||
)
|
||||
end
|
||||
|
||||
defp report_rebuild_phase(nil, _value, _label), do: :ok
|
||||
|
||||
defp report_rebuild_phase(callback, value, label) do
|
||||
callback.(value, label)
|
||||
:ok
|
||||
end
|
||||
defp report_rebuild_phase(callback, value, label),
|
||||
do: ProgressReporter.report_phase(callback, value, label)
|
||||
|
||||
defp snapshot_content_hash(project_id, post_id) do
|
||||
case Index.read(project_id) do
|
||||
|
||||
@@ -6,6 +6,7 @@ defmodule BDS.Embeddings.Index do
|
||||
alias BDS.Persistence
|
||||
alias BDS.Embeddings.Key
|
||||
alias BDS.Projects
|
||||
alias BDS.ProgressReporter
|
||||
alias BDS.Repo
|
||||
|
||||
@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), do: {post_id_b, post_id_a}
|
||||
|
||||
defp progress_callback(opts) do
|
||||
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 progress_callback(opts), do: ProgressReporter.callback(opts)
|
||||
|
||||
defp report_scan_started(callback, total, label) do
|
||||
callback.(0.0, "Scanning 0/#{total} #{label}")
|
||||
:ok
|
||||
ProgressReporter.report_count_started(callback, total, label,
|
||||
verb: "Scanning",
|
||||
start_progress: 0.0,
|
||||
empty_suffix: "to scan",
|
||||
message_style: :prefix_count
|
||||
)
|
||||
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
|
||||
callback.(current / total, "Scanning #{current}/#{total} #{label}")
|
||||
:ok
|
||||
ProgressReporter.report_count_progress(callback, current, total, label,
|
||||
verb: "Scanning",
|
||||
start_progress: 0.0,
|
||||
message_style: :prefix_count
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,73 +9,61 @@ defmodule BDS.Generation.Progress do
|
||||
|
||||
@doc "Extract the `:on_progress` callback from a keyword list of options."
|
||||
@spec callback(keyword()) :: callback()
|
||||
def callback(opts) do
|
||||
case Keyword.get(opts, :on_progress) do
|
||||
cb when is_function(cb, 2) -> cb
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
def callback(opts), do: BDS.ProgressReporter.callback(opts)
|
||||
|
||||
@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
|
||||
callback.(0.0, "Processing 0/#{total} #{label}")
|
||||
:ok
|
||||
BDS.ProgressReporter.report_count_started(callback, total, label,
|
||||
verb: "Processing",
|
||||
start_progress: 0.0,
|
||||
empty_suffix: "to process",
|
||||
message_style: :prefix_count
|
||||
)
|
||||
end
|
||||
|
||||
@spec report_generation_progress(callback(), non_neg_integer(), non_neg_integer(), String.t()) :: :ok
|
||||
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}")
|
||||
@spec report_generation_progress(callback(), non_neg_integer(), non_neg_integer(), String.t()) ::
|
||||
: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
|
||||
|
||||
@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
|
||||
callback.(progress, message)
|
||||
@spec report_validation_snapshot_progress(callback(), atom(), non_neg_integer(), integer()) ::
|
||||
: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
|
||||
progress = min(0.18, current / total * 0.18)
|
||||
callback.(progress, "Collecting sitemap URLs... #{current}/#{total}")
|
||||
:ok
|
||||
BDS.ProgressReporter.report_count_progress(callback, current, total, "sitemap URLs...",
|
||||
verb: "Collecting",
|
||||
range: {0.0, 0.18},
|
||||
message_style: :verb_label_count
|
||||
)
|
||||
end
|
||||
|
||||
def report_validation_snapshot_progress(callback, :translations, current, total) do
|
||||
progress = 0.18 + min(0.12, current / total * 0.12)
|
||||
callback.(progress, "Collecting sitemap URLs... #{current}/#{total}")
|
||||
:ok
|
||||
BDS.ProgressReporter.report_count_progress(callback, current, total, "sitemap URLs...",
|
||||
verb: "Collecting",
|
||||
range: {0.18, 0.30},
|
||||
message_style: :verb_label_count
|
||||
)
|
||||
end
|
||||
|
||||
@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
|
||||
progress = min(0.49, 0.30 + current / total * 0.19)
|
||||
callback.(progress, "Collecting sitemap URLs... #{current}/#{total}")
|
||||
:ok
|
||||
BDS.ProgressReporter.report_count_progress(callback, current, total, "sitemap URLs...",
|
||||
verb: "Collecting",
|
||||
range: {0.30, 0.49},
|
||||
message_style: :verb_label_count
|
||||
)
|
||||
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(_callback, _stage, _current, total) when total <= 0, do: :ok
|
||||
|
||||
@@ -85,12 +73,15 @@ defmodule BDS.Generation.Progress do
|
||||
end
|
||||
|
||||
@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
|
||||
progress = min(0.99, 0.5 + current / total * 0.49)
|
||||
callback.(progress, "Comparing sitemap to html pages... #{current}/#{total}")
|
||||
:ok
|
||||
BDS.ProgressReporter.report_count_progress(
|
||||
callback,
|
||||
current,
|
||||
total,
|
||||
"sitemap to html pages...",
|
||||
verb: "Comparing",
|
||||
range: {0.5, 0.99},
|
||||
message_style: :verb_label_count
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,45 +1,31 @@
|
||||
defmodule BDS.Maintenance.Progress do
|
||||
@moduledoc false
|
||||
|
||||
def progress_callback(opts) do
|
||||
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 progress_callback(opts), do: BDS.ProgressReporter.callback(opts)
|
||||
|
||||
def report_metadata_diff_phase(callback, current, total, label) do
|
||||
value = if total <= 1, do: 0.0, else: (current - 1) / total
|
||||
callback.(value, "#{label} (#{current}/#{total})")
|
||||
:ok
|
||||
progress = if total <= 1, do: 0.0, else: (current - 1) / total
|
||||
BDS.ProgressReporter.report_phase(callback, progress, "#{label} (#{current}/#{total})")
|
||||
end
|
||||
|
||||
def report_metadata_diff_complete(nil), do: :ok
|
||||
|
||||
def report_metadata_diff_complete(callback) do
|
||||
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
|
||||
BDS.ProgressReporter.report_phase(callback, 1.0, "Metadata diff complete")
|
||||
end
|
||||
|
||||
def report_started(callback, total, label) do
|
||||
callback.(0.05, "#{label} (0/#{total})")
|
||||
:ok
|
||||
BDS.ProgressReporter.report_count_started(callback, total, label,
|
||||
verb: nil,
|
||||
start_progress: 0.05,
|
||||
empty_message: label,
|
||||
message_style: :label
|
||||
)
|
||||
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
|
||||
callback.(0.05 + 0.95 * (current / total), "#{label} (#{current}/#{total})")
|
||||
:ok
|
||||
BDS.ProgressReporter.report_count_progress(callback, current, total, label,
|
||||
verb: nil,
|
||||
start_progress: 0.05,
|
||||
message_style: :label
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
24
lib/bds/map_utils.ex
Normal file
24
lib/bds/map_utils.ex
Normal 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
|
||||
@@ -22,8 +22,7 @@ defmodule BDS.MCP.Util do
|
||||
def normalize_term(value), do: value |> to_string() |> String.downcase()
|
||||
|
||||
@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)
|
||||
def maybe_put(map, key, value), do: BDS.MapUtils.maybe_put(map, key, value)
|
||||
|
||||
@spec map_get(map(), atom(), term()) :: term()
|
||||
def map_get(map, key, default \\ nil) do
|
||||
|
||||
@@ -2,28 +2,12 @@ defmodule BDS.Media.FileOps do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.Persistence
|
||||
alias BDS.ProgressReporter
|
||||
alias BDS.Projects
|
||||
|
||||
@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(), 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
|
||||
defdelegate attr(attrs, key), to: BDS.MapUtils
|
||||
defdelegate maybe_put(map, key, value), to: BDS.MapUtils
|
||||
defdelegate blank_to_nil(value), to: BDS.MapUtils
|
||||
|
||||
@spec atomic_write(Path.t(), iodata()) :: :ok | {:error, term()}
|
||||
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/")
|
||||
|
||||
@spec progress_callback(keyword()) :: (float(), String.t() -> any()) | nil
|
||||
def progress_callback(opts) do
|
||||
case Keyword.get(opts, :on_progress) do
|
||||
callback when is_function(callback, 2) -> callback
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
def progress_callback(opts), do: ProgressReporter.callback(opts)
|
||||
|
||||
@spec scaled_progress_reporter((float(), String.t() -> any()) | nil, float(), float()) ::
|
||||
(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
|
||||
fn value, message ->
|
||||
scaled_value = start_value + (end_value - start_value) * value
|
||||
report.(scaled_value, message)
|
||||
end
|
||||
end
|
||||
|
||||
@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_started(
|
||||
(float(), String.t() -> any()) | nil,
|
||||
non_neg_integer(),
|
||||
String.t()
|
||||
) :: :ok
|
||||
def report_rebuild_started(callback, total, label),
|
||||
do: ProgressReporter.report_rebuild_started(callback, total, label)
|
||||
|
||||
@spec report_rebuild_progress(
|
||||
(float(), String.t() -> any()) | nil,
|
||||
@@ -132,19 +100,10 @@ defmodule BDS.Media.FileOps do
|
||||
non_neg_integer(),
|
||||
String.t()
|
||||
) :: :ok
|
||||
def report_rebuild_progress(nil, _current, _total, _label), do: :ok
|
||||
def report_rebuild_progress(_callback, _current, 0, _label), do: :ok
|
||||
|
||||
def report_rebuild_progress(callback, current, total, label) do
|
||||
callback.(0.05 + 0.95 * (current / total), "Rebuilding #{label} (#{current}/#{total})")
|
||||
:ok
|
||||
end
|
||||
def report_rebuild_progress(callback, current, total, label),
|
||||
do: ProgressReporter.report_rebuild_progress(callback, current, total, label)
|
||||
|
||||
@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
|
||||
callback.(progress, message)
|
||||
:ok
|
||||
end
|
||||
def report_rebuild_phase(callback, progress, message),
|
||||
do: ProgressReporter.report_phase(callback, progress, message)
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule BDS.Posts do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||
|
||||
alias BDS.Embeddings
|
||||
alias BDS.Media
|
||||
@@ -193,7 +194,8 @@ defmodule BDS.Posts do
|
||||
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
|
||||
|
||||
@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)
|
||||
|
||||
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, restored_post}
|
||||
else
|
||||
@@ -262,7 +265,8 @@ defmodule BDS.Posts do
|
||||
full_path = Path.join(Projects.project_data_dir(project), post.file_path)
|
||||
|
||||
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, repaired_post}
|
||||
else
|
||||
@@ -405,7 +409,9 @@ defmodule BDS.Posts do
|
||||
|
||||
@spec rebuild_post_links(String.t(), rebuild_opts()) :: :ok
|
||||
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)
|
||||
|
||||
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)
|
||||
:ok = RebuildFromFiles.report_rebuild_started(on_progress, total_posts, "post links")
|
||||
|
||||
@@ -422,7 +435,9 @@ defmodule BDS.Posts do
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.each(fn {post, index} ->
|
||||
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)
|
||||
|
||||
:ok
|
||||
@@ -438,8 +453,11 @@ defmodule BDS.Posts do
|
||||
@spec delete_post_translation(String.t()) :: {:ok, :deleted} | {:error, :not_found}
|
||||
defdelegate delete_post_translation(translation_id), to: Translations
|
||||
|
||||
@spec validate_translations(String.t(), rebuild_opts()) :: {:ok, translation_validation_report()}
|
||||
defdelegate validate_translations(project_id, opts \\ []), to: TranslationValidation, as: :validate
|
||||
@spec validate_translations(String.t(), rebuild_opts()) ::
|
||||
{:ok, translation_validation_report()}
|
||||
defdelegate validate_translations(project_id, opts \\ []),
|
||||
to: TranslationValidation,
|
||||
as: :validate
|
||||
|
||||
@spec fix_invalid_translations(map()) ::
|
||||
{:ok,
|
||||
@@ -458,6 +476,7 @@ defmodule BDS.Posts do
|
||||
project = Projects.get_project!(post.project_id)
|
||||
full_path = Path.join(Projects.project_data_dir(project), post.file_path)
|
||||
body = published_post_body(post, full_path)
|
||||
|
||||
:ok =
|
||||
Persistence.atomic_write(
|
||||
full_path,
|
||||
@@ -544,9 +563,6 @@ defmodule BDS.Posts do
|
||||
)
|
||||
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(title), do: title
|
||||
|
||||
@@ -564,12 +580,4 @@ defmodule BDS.Posts do
|
||||
defp has_attr?(attrs, key) do
|
||||
Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key))
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Posts.RebuildFromFiles do
|
||||
alias BDS.Embeddings
|
||||
alias BDS.Frontmatter
|
||||
alias BDS.Persistence
|
||||
alias BDS.ProgressReporter
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Posts.Slugs
|
||||
alias BDS.Posts.Translation
|
||||
@@ -113,7 +114,9 @@ defmodule BDS.Posts.RebuildFromFiles do
|
||||
with {:ok, rebuild_file} <- parse_rebuild_file(project, full_path) do
|
||||
if TranslationValidation.translation_rebuild_file?(rebuild_file) do
|
||||
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
|
||||
nil ->
|
||||
@@ -267,50 +270,21 @@ defmodule BDS.Posts.RebuildFromFiles do
|
||||
def parse_translation_status(status), do: String.to_existing_atom(status)
|
||||
|
||||
@doc false
|
||||
def progress_callback(opts) do
|
||||
case Keyword.get(opts, :on_progress) do
|
||||
callback when is_function(callback, 2) -> callback
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
def progress_callback(opts), do: ProgressReporter.callback(opts)
|
||||
|
||||
@doc false
|
||||
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
|
||||
def report_rebuild_started(callback, total, label),
|
||||
do: ProgressReporter.report_rebuild_started(callback, total, label)
|
||||
|
||||
@doc false
|
||||
def report_rebuild_progress(nil, _current, _total, _label), do: :ok
|
||||
def report_rebuild_progress(_callback, _current, 0, _label), do: :ok
|
||||
def report_rebuild_progress(callback, current, total, label),
|
||||
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
|
||||
defp scaled_progress_reporter(report, start_value, end_value),
|
||||
do: ProgressReporter.scaled(report, start_value, end_value)
|
||||
|
||||
defp scaled_progress_reporter(nil, _start_value, _end_value), do: nil
|
||||
|
||||
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 report_rebuild_phase(callback, progress, message),
|
||||
do: ProgressReporter.report_phase(callback, progress, message)
|
||||
|
||||
defp unique_post_id(nil), do: Ecto.UUID.generate()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule BDS.Posts.Translations do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||
|
||||
alias BDS.Persistence
|
||||
alias BDS.Posts
|
||||
@@ -265,15 +266,4 @@ defmodule BDS.Posts.Translations do
|
||||
|> String.split("-", parts: 2)
|
||||
|> hd()
|
||||
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
|
||||
|
||||
116
lib/bds/progress_reporter.ex
Normal file
116
lib/bds/progress_reporter.ex
Normal 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
|
||||
@@ -3,6 +3,8 @@ defmodule BDS.Publishing do
|
||||
|
||||
use GenServer
|
||||
|
||||
import BDS.MapUtils, only: [attr: 2]
|
||||
|
||||
alias BDS.Persistence
|
||||
alias BDS.Publishing.PublishJob
|
||||
alias BDS.Projects
|
||||
@@ -44,7 +46,9 @@ defmodule BDS.Publishing do
|
||||
def handle_call({:update_job, job_id, attrs}, _from, state) do
|
||||
reply =
|
||||
case Repo.get(PublishJob, job_id) do
|
||||
nil -> :ok
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
job ->
|
||||
attrs = Map.put(attrs, :updated_at, Persistence.now_ms())
|
||||
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("rsync"), do: :rsync
|
||||
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
|
||||
|
||||
@@ -2,10 +2,12 @@ defmodule BDS.Scripts do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||
|
||||
alias BDS.DocumentFields
|
||||
alias BDS.Frontmatter
|
||||
alias BDS.Persistence
|
||||
alias BDS.ProgressReporter
|
||||
alias BDS.Projects
|
||||
alias BDS.Repo
|
||||
alias BDS.Scripts.Script
|
||||
@@ -321,45 +323,15 @@ defmodule BDS.Scripts do
|
||||
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
|
||||
Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key))
|
||||
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
|
||||
defp progress_callback(opts), do: ProgressReporter.callback(opts)
|
||||
|
||||
defp progress_callback(opts) do
|
||||
case Keyword.get(opts, :on_progress) do
|
||||
callback when is_function(callback, 2) -> callback
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
defp report_rebuild_started(callback, total, label),
|
||||
do: ProgressReporter.report_rebuild_started(callback, total, label)
|
||||
|
||||
defp report_rebuild_started(nil, _total, _label), do: :ok
|
||||
|
||||
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
|
||||
defp report_rebuild_progress(callback, current, total, label),
|
||||
do: ProgressReporter.report_rebuild_progress(callback, current, total, label)
|
||||
end
|
||||
|
||||
@@ -2,11 +2,13 @@ defmodule BDS.Search do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
import BDS.MapUtils, only: [attr: 2]
|
||||
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Media.Translation, as: MediaTranslation
|
||||
alias BDS.Persistence
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.ProgressReporter
|
||||
alias BDS.Projects
|
||||
alias BDS.Repo
|
||||
|
||||
@@ -244,31 +246,23 @@ defmodule BDS.Search do
|
||||
:ok
|
||||
end
|
||||
|
||||
defp progress_callback(opts) do
|
||||
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 progress_callback(opts), do: ProgressReporter.callback(opts)
|
||||
|
||||
defp report_reindex_started(callback, total, label) do
|
||||
callback.(0.0, "Reindexing 0/#{total} #{label}")
|
||||
:ok
|
||||
ProgressReporter.report_count_started(callback, total, label,
|
||||
verb: "Reindexing",
|
||||
start_progress: 0.0,
|
||||
empty_suffix: "to reindex",
|
||||
message_style: :prefix_count
|
||||
)
|
||||
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
|
||||
callback.(current / total, "Reindexing #{current}/#{total} #{label}")
|
||||
:ok
|
||||
ProgressReporter.report_count_progress(callback, current, total, label,
|
||||
verb: "Reindexing",
|
||||
start_progress: 0.0,
|
||||
message_style: :prefix_count
|
||||
)
|
||||
end
|
||||
|
||||
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_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
|
||||
case Date.from_iso8601(value) do
|
||||
@@ -680,12 +676,4 @@ defmodule BDS.Search do
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -2,10 +2,12 @@ defmodule BDS.Templates do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||
|
||||
alias BDS.DocumentFields
|
||||
alias BDS.Frontmatter
|
||||
alias BDS.Persistence
|
||||
alias BDS.ProgressReporter
|
||||
alias BDS.Posts
|
||||
alias BDS.Projects
|
||||
alias BDS.Repo
|
||||
@@ -522,45 +524,15 @@ defmodule BDS.Templates do
|
||||
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
|
||||
Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key))
|
||||
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
|
||||
defp progress_callback(opts), do: ProgressReporter.callback(opts)
|
||||
|
||||
defp progress_callback(opts) do
|
||||
case Keyword.get(opts, :on_progress) do
|
||||
callback when is_function(callback, 2) -> callback
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
defp report_rebuild_started(callback, total, label),
|
||||
do: ProgressReporter.report_rebuild_started(callback, total, label)
|
||||
|
||||
defp report_rebuild_started(nil, _total, _label), do: :ok
|
||||
|
||||
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
|
||||
defp report_rebuild_progress(callback, current, total, label),
|
||||
do: ProgressReporter.report_rebuild_progress(callback, current, total, label)
|
||||
end
|
||||
|
||||
31
test/bds/map_utils_test.exs
Normal file
31
test/bds/map_utils_test.exs
Normal 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
|
||||
140
test/bds/progress_reporter_test.exs
Normal file
140
test/bds/progress_reporter_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user