Compare commits
7 Commits
abbcef594a
...
caaec98225
| Author | SHA1 | Date | |
|---|---|---|---|
| caaec98225 | |||
| 4b1557cf6a | |||
| 985d8b53c2 | |||
| 941db4c6f4 | |||
| a73af6b44d | |||
| e2054c9c12 | |||
| 28e08451e4 |
53
.credo.exs
Normal file
53
.credo.exs
Normal file
@@ -0,0 +1,53 @@
|
||||
%{
|
||||
configs: [
|
||||
%{
|
||||
name: "default",
|
||||
files: %{
|
||||
included: ["lib/", "test/", "config/", "mix.exs"],
|
||||
excluded: [~r"/deps/", ~r"/_build/", ~r"/priv/static/"]
|
||||
},
|
||||
strict: true,
|
||||
parse_timeout: 10_000,
|
||||
color: true,
|
||||
checks: [
|
||||
{Credo.Check.Consistency.ExceptionNames},
|
||||
{Credo.Check.Consistency.LineEndings},
|
||||
{Credo.Check.Consistency.SpaceAroundOperators},
|
||||
{Credo.Check.Consistency.SpaceInParentheses},
|
||||
{Credo.Check.Consistency.TabsOrSpaces},
|
||||
{Credo.Check.Design.AliasUsage, false},
|
||||
{Credo.Check.Readability.BlockPipe, false},
|
||||
{Credo.Check.Readability.AliasOrder, false},
|
||||
{Credo.Check.Readability.LargeNumbers, false},
|
||||
{Credo.Check.Readability.MaxLineLength, false},
|
||||
{Credo.Check.Readability.ModuleDoc, false},
|
||||
{Credo.Check.Readability.PreferImplicitTry, false},
|
||||
{Credo.Check.Readability.Semicolons, false},
|
||||
{Credo.Check.Readability.StringSigils, false},
|
||||
{Credo.Check.Readability.TrailingBlankLine, false},
|
||||
{Credo.Check.Readability.UnnecessaryAliasExpansion, false},
|
||||
{Credo.Check.Readability.WithSingleClause, false},
|
||||
{Credo.Check.Refactor.Apply, false},
|
||||
{Credo.Check.Refactor.CondStatements, false},
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, false},
|
||||
{Credo.Check.Refactor.FilterFilter, false},
|
||||
{Credo.Check.Refactor.FilterReject, false},
|
||||
{Credo.Check.Refactor.FunctionArity, false},
|
||||
{Credo.Check.Refactor.MapJoin, false},
|
||||
{Credo.Check.Refactor.Nesting, false},
|
||||
{Credo.Check.Refactor.NegatedConditionsWithElse, false},
|
||||
{Credo.Check.Refactor.RejectFilter, false},
|
||||
{Credo.Check.Refactor.RejectReject, false},
|
||||
{Credo.Check.Refactor.RedundantWithClauseResult, false},
|
||||
{Credo.Check.Warning.ApplicationConfigInModuleAttribute},
|
||||
{Credo.Check.Warning.BoolOperationOnSameValues},
|
||||
{Credo.Check.Warning.ExpensiveEmptyEnumCheck},
|
||||
{Credo.Check.Warning.IExPry},
|
||||
{Credo.Check.Warning.LazyLogging},
|
||||
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, false},
|
||||
{Credo.Check.Warning.OperationOnSameValues},
|
||||
{Credo.Check.Warning.RaiseInsideRescue}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.mix_audit.ignore
Normal file
4
.mix_audit.ignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# GHSA-rhv4-8758-jx7v is pinned transitively through bumblebee -> progress_bar.
|
||||
# ecto_sqlite3 0.24.x can move to decimal 3.x, but that line is currently
|
||||
# unsatisfiable alongside the app's Bumblebee dependency.
|
||||
GHSA-rhv4-8758-jx7v
|
||||
@@ -28,7 +28,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
||||
- we have an allium spec in the specs/ folder. you must weed the specs against built code to make sure you follow the spec.
|
||||
- when changing the spec, validate the spec with the available command line tool.
|
||||
- you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
|
||||
- you MUST run build, test and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests and clean dialyzer results
|
||||
- you MUST run build, test, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits and clean dialyzer results
|
||||
- on a headless Linux machine, you have to run tests with this command (if mix test complains about DISPLAX): xvfb-run mix test
|
||||
|
||||
---
|
||||
@@ -55,7 +55,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
||||
|
||||
- Never leave tests failing, even if they appear unrelated to your changes
|
||||
- If a test failure is pre-existing, fix it as part of your current work
|
||||
- Run the full test suite (`npm test`) before considering any task complete
|
||||
- Run the full test suite (`mix test`) before considering any task complete
|
||||
- If you cannot fix a test, explain why and propose a solution
|
||||
|
||||
> **Zero failing tests. No exceptions.**
|
||||
@@ -106,7 +106,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
||||
|
||||
**All user-facing text MUST follow proper i18n patterns.**
|
||||
|
||||
- Do not hardcode UI strings directly in React components, menu templates, dialogs, or toasts
|
||||
- Do not hardcode UI strings directly in LiveView/HEEx components, menu templates, dialogs, or toasts
|
||||
- Store UI copy in language resources and resolve text through i18n helpers/hooks
|
||||
- UI language MUST come from the operating system locale
|
||||
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
|
||||
|
||||
88
TECHDEBTS.md
88
TECHDEBTS.md
@@ -691,7 +691,19 @@ to `@stemmer_algorithms` keys; default `"en"` unchanged. Delete
|
||||
**Acceptance.** Misclassification fixtures (umlaut-free German, accented-free
|
||||
French) detect correctly; stemmer selection unchanged for English.
|
||||
|
||||
### TD-18: Evaluate Oban (Lite engine) for durable jobs
|
||||
### TD-18: Evaluate Oban (Lite engine) for durable jobs ✅ DONE (2026-06-12)
|
||||
|
||||
**Status: evaluated; no-go for now.** `BDS.Publishing` already persists
|
||||
`PublishJob` rows and the suite proves those rows survive a
|
||||
`BDS.Publishing` restart; what it does **not** provide is durable mid-upload
|
||||
resume/retry, and the current desktop spec does not require that behavior.
|
||||
Moving publishing to Oban Lite now would add another coordination layer on top
|
||||
of `BDS.Tasks`, force a redesign of the in-memory SCP mtime cache in
|
||||
`BDS.Publishing`, and complicate the existing Ecto sandbox/release split for a
|
||||
benefit that is not yet part of the product contract. `BDS.Scripting.JobStore`
|
||||
and `BDS.Tasks` remain intentionally in-memory: they provide UI/runtime job
|
||||
tracking, not durable work queues. Re-open this only if real crash-recovery
|
||||
requirements emerge and a spike shows acceptable desktop binary/startup impact.
|
||||
|
||||
**Context.** Three overlapping job systems exist: in-memory UI tasks
|
||||
(`BDS.Tasks`), DB-persisted publish jobs (`Publishing` + `PublishJob` rows),
|
||||
@@ -712,10 +724,25 @@ check interplay with the test sandbox and with the `bds_mcp` release. Write
|
||||
up a go/no-go with the spike branch. If no-go, document why in this file and
|
||||
close.
|
||||
|
||||
**Acceptance.** Decision documented; if go: publishing migrated first, with
|
||||
crash-recovery test (kill app mid-upload, job resumes/retries on restart).
|
||||
**Acceptance.** Decision documented. Outcome: no-go for now; publishing keeps
|
||||
its current persisted-status model and the task closes without an Oban
|
||||
migration.
|
||||
|
||||
### TD-19: Add credo, mix_audit (and consider sobelow) to the quality gates
|
||||
### TD-19: Add credo, mix_audit (and consider sobelow) to the quality gates ✅ DONE (2026-06-12)
|
||||
|
||||
**Status: implemented.** `mix.exs` now adds `credo` and `mix_audit` as
|
||||
dev/test-only tooling, and `mix validate` runs in the test environment with
|
||||
all four gates wired in: tests, Credo, dependency audit, and dialyzer.
|
||||
`.credo.exs` deliberately scopes the initial baseline to stable, actionable
|
||||
checks and explicitly disables the repo-wide legacy style/refactor rules that
|
||||
would otherwise drown the signal. The dependency audit is clean except for one
|
||||
documented upstream exception in `.mix_audit.ignore`:
|
||||
`GHSA-rhv4-8758-jx7v` remains unavoidable while `bumblebee` depends on
|
||||
`progress_bar ~> 3.0`, which pins `decimal ~> 2.0`; the higher-signal Phoenix
|
||||
advisory was removed by updating the lockfile to `phoenix 1.8.8`.
|
||||
Sobelow was evaluated and not adopted: for this desktop app's loopback-only
|
||||
endpoint it would mostly duplicate checks already covered by compile/test,
|
||||
Credo, and dependency audit while adding little release signal.
|
||||
|
||||
**Context.** The project enforces dialyzer + warnings-as-errors but has no
|
||||
style/consistency linter and no dependency CVE audit. Cheap, high-leverage
|
||||
@@ -739,7 +766,16 @@ instructions (AGENTS.md) updated to mention them.
|
||||
|
||||
## Phase 5 — Consistency, config & hygiene
|
||||
|
||||
### TD-20: Align SQLite pool configuration between dev and prod
|
||||
### TD-20: Align SQLite pool configuration between dev and prod ✅ DONE (2026-06-12)
|
||||
|
||||
**Status: implemented.** The effective mismatch was narrowed to one place:
|
||||
`config.exs`, `dev.exs`, `prod.exs`, and `test.exs` were already aligned on a
|
||||
modest pool of `5`, but `config/runtime.exs` still defaulted production to
|
||||
`POOL_SIZE=1`. The prod runtime default now also uses `5`, with an inline
|
||||
comment documenting the rationale: keep SQLite concurrency behavior consistent
|
||||
across dev and prod under the existing WAL + `busy_timeout` setup unless an
|
||||
operator explicitly overrides `POOL_SIZE`. A regression test reads
|
||||
`config/runtime.exs` in `:prod` and locks that default to `5`.
|
||||
|
||||
**Context.** Dev runs `pool_size: 5` (config.exs), prod runs `pool_size: 1`
|
||||
(runtime.exs) — so dev and prod have **different concurrency semantics**: dev
|
||||
@@ -760,7 +796,15 @@ setting.
|
||||
**Acceptance.** Same pool model in dev and prod; rationale comment in config;
|
||||
no busy-timeout regressions in tests.
|
||||
|
||||
### TD-21: Harden `Persistence.atomic_write`
|
||||
### TD-21: Harden `Persistence.atomic_write` ✅ DONE (2026-06-12)
|
||||
|
||||
**Status: implemented.** `BDS.Persistence.atomic_write/2` now writes through a
|
||||
per-call temp path (`.tmp.<unique_integer>`) instead of the fixed `.tmp`
|
||||
suffix, eliminating the concurrent-writer collision that previously produced
|
||||
`:enoent` races. The new test coverage proves two things: many simultaneous
|
||||
writers to the same target all complete successfully and the final file is one
|
||||
intact payload, and the post-rebuild glob still ignores atomic temp files
|
||||
because the temp suffix no longer matches the `*.md` pattern.
|
||||
|
||||
**Context.** `atomic_write/2` uses a fixed `path <> ".tmp"` temp name — two
|
||||
concurrent writers to the same path corrupt each other's temp file before
|
||||
@@ -780,7 +824,17 @@ are scanned (check rebuild-from-files glob patterns ignore them).
|
||||
**Acceptance.** Concurrent-writer test produces two intact outcomes (last
|
||||
write wins, no corruption); rebuild file globs ignore temp files.
|
||||
|
||||
### TD-22: Wrap `delete_chat_conversation` in a transaction
|
||||
### TD-22: Wrap `delete_chat_conversation` in a transaction ✅ DONE (2026-06-12)
|
||||
|
||||
**Status: implemented.** `BDS.AI.Chat.delete_chat_conversation/1` now performs
|
||||
message deletion and conversation deletion inside a single `Repo.transaction`.
|
||||
If the second step fails, the whole delete rolls back and both the
|
||||
conversation and its messages remain intact. The regression test injects a
|
||||
mid-delete failure through a local test hook and proves the rollback by
|
||||
asserting both rows still exist afterward. The nearby audit requested by this
|
||||
TD found no equivalent multi-statement write outlier in `lib/bds/ai/catalog.ex`
|
||||
or `lib/bds/scripts.ex`: catalog refresh already runs in a transaction and the
|
||||
script operations are single-row writes.
|
||||
|
||||
**Context.** `chat.ex` deletes all messages (`Repo.delete_all`) then the
|
||||
conversation (`Repo.delete`) without a transaction — a failure in between
|
||||
@@ -801,7 +855,19 @@ add `ON DELETE CASCADE` via an Ecto migration on the
|
||||
**Acceptance.** Injected failure between the two deletes leaves both intact;
|
||||
no orphaned messages possible.
|
||||
|
||||
### TD-23: Sweep blanket `rescue`/`catch` blocks for silent failure
|
||||
### TD-23: Sweep blanket `rescue`/`catch` blocks for silent failure ✅ DONE (2026-06-12)
|
||||
|
||||
**Status: implemented.** The named sweep surface was tightened in the intended
|
||||
way instead of being rewritten wholesale. `ImportAnalysis` and
|
||||
`ImportExecution` now log contextual warnings when top-level import handling,
|
||||
repo-transaction wrappers, media linking, or progress callbacks fall back after
|
||||
exceptions; the tests now prove those progress-callback failures are no longer
|
||||
silent. In `ImportAnalysis.year_from/1`, the blanket integer-date rescue was
|
||||
narrowed to `ArgumentError`, which is the actual parsing failure path there.
|
||||
`OverlayComponents` kept its defensive UI fallbacks, but every rescue now logs
|
||||
why the fallback path was taken instead of silently swallowing the error.
|
||||
`MainWindow` needed no change in this pass: its catches are already scoped to
|
||||
expected `:exit`/wx failures rather than bare rescue-all blocks.
|
||||
|
||||
**Context.** Several modules rescue *all* exceptions into `:ok`/fallbacks
|
||||
(`shutdown.ex` defensibly — it must never block quit; but also
|
||||
@@ -821,7 +887,11 @@ call, and delete rescues that guard nothing. Shutdown.ex's rescues stay as-is
|
||||
**Acceptance.** No bare `rescue _ ->` without either a narrow exception match
|
||||
or a logged justification; suite green.
|
||||
|
||||
### TD-24: Fix stale `npm test` reference in AGENTS.md
|
||||
### TD-24: Fix stale `npm test` reference in AGENTS.md ✅ DONE (2026-06-12)
|
||||
|
||||
**Status: implemented.** `AGENTS.md` now tells agents to run `mix test`
|
||||
instead of the stale `npm test`, and the nearby UI i18n wording was updated to
|
||||
refer to LiveView/HEEx components rather than leftover React-era terminology.
|
||||
|
||||
**Context.** AGENTS.md "Fix All Test Failures" section says "Run the full
|
||||
test suite (`npm test`)" — stale from the pre-Elixir rewrite. It's an
|
||||
|
||||
@@ -7,9 +7,11 @@ if config_env() == :prod do
|
||||
|
||||
File.mkdir_p!(Path.dirname(database_path))
|
||||
|
||||
# Keep prod on the same modest SQLite pool as dev so WAL + busy_timeout see
|
||||
# the same concurrency behavior in both environments unless explicitly tuned.
|
||||
config :bds, BDS.Repo,
|
||||
database: database_path,
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1")
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
|
||||
|
||||
# Persist downloaded embedding model files alongside the database data dir.
|
||||
config :bumblebee,
|
||||
|
||||
@@ -106,12 +106,24 @@ defmodule BDS.AI.Chat do
|
||||
{:error, :not_found}
|
||||
|
||||
%ChatConversation{} = conversation ->
|
||||
Repo.delete_all(
|
||||
from message in ChatMessage, where: message.conversation_id == ^conversation_id
|
||||
)
|
||||
Repo.transaction(fn ->
|
||||
Repo.delete_all(
|
||||
from message in ChatMessage, where: message.conversation_id == ^conversation_id
|
||||
)
|
||||
|
||||
case Repo.delete(conversation) do
|
||||
{:ok, _conversation} -> {:ok, :deleted}
|
||||
case delete_chat_conversation_test_hook(conversation_id) do
|
||||
:ok ->
|
||||
case Repo.delete(conversation) do
|
||||
{:ok, _conversation} -> :ok
|
||||
{:error, reason} -> Repo.rollback(reason)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Repo.rollback(reason)
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, :ok} -> {:ok, :deleted}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
@@ -610,6 +622,13 @@ defmodule BDS.AI.Chat do
|
||||
end
|
||||
end
|
||||
|
||||
defp delete_chat_conversation_test_hook(conversation_id) do
|
||||
case Application.get_env(:bds, :chat_delete_conversation_test_hook) do
|
||||
hook when is_function(hook, 1) -> hook.(conversation_id)
|
||||
_other -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp persist_assistant_response(conversation_id, response) do
|
||||
usage = normalize_usage(response.usage)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
import Ecto.Query
|
||||
require Logger
|
||||
|
||||
alias BDS.{I18n, Media, Metadata, Posts, Repo}
|
||||
alias BDS.Media.Media, as: MediaRecord
|
||||
@@ -70,7 +71,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
metadata
|
||||
rescue
|
||||
_error -> %{main_language: "en", blog_languages: []}
|
||||
error ->
|
||||
log_overlay_warning("project metadata", error)
|
||||
%{main_language: "en", blog_languages: []}
|
||||
end
|
||||
|
||||
defp posts(nil), do: []
|
||||
@@ -137,7 +140,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
select: pm.media_id
|
||||
)
|
||||
rescue
|
||||
_error -> []
|
||||
error ->
|
||||
log_overlay_warning("post media ids for #{post_id}", error)
|
||||
[]
|
||||
end
|
||||
|
||||
defp post_media_ids(_tab), do: []
|
||||
@@ -150,7 +155,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
)
|
||||
|> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end)
|
||||
rescue
|
||||
_error -> %{}
|
||||
error ->
|
||||
log_overlay_warning("post translations for #{post_id}", error)
|
||||
%{}
|
||||
end
|
||||
|
||||
defp existing_translations(%{type: :media, id: media_id}) do
|
||||
@@ -161,7 +168,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
)
|
||||
|> Map.new(fn {language, status} -> {language, status} end)
|
||||
rescue
|
||||
_error -> %{}
|
||||
error ->
|
||||
log_overlay_warning("media translations for #{media_id}", error)
|
||||
%{}
|
||||
end
|
||||
|
||||
defp existing_translations(_tab), do: %{}
|
||||
@@ -179,7 +188,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
_other -> metadata.main_language || "en"
|
||||
end
|
||||
rescue
|
||||
_error -> metadata.main_language || "en"
|
||||
error ->
|
||||
log_overlay_warning("post source language for #{post_id}", error)
|
||||
metadata.main_language || "en"
|
||||
end
|
||||
|
||||
defp source_language(%{type: :media, id: media_id}, metadata) do
|
||||
@@ -188,7 +199,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
_other -> metadata.main_language || "en"
|
||||
end
|
||||
rescue
|
||||
_error -> metadata.main_language || "en"
|
||||
error ->
|
||||
log_overlay_warning("media source language for #{media_id}", error)
|
||||
metadata.main_language || "en"
|
||||
end
|
||||
|
||||
defp source_language(_tab, metadata), do: metadata.main_language || "en"
|
||||
@@ -242,7 +255,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
[]
|
||||
end
|
||||
rescue
|
||||
_error -> []
|
||||
error ->
|
||||
log_overlay_warning("post AI fields for #{post_id}", error)
|
||||
[]
|
||||
end
|
||||
|
||||
defp ai_fields(%{type: :media, id: media_id}, title, _subtitle, page_language) do
|
||||
@@ -279,7 +294,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
[]
|
||||
end
|
||||
rescue
|
||||
_error -> []
|
||||
error ->
|
||||
log_overlay_warning("media AI fields for #{media_id}", error)
|
||||
[]
|
||||
end
|
||||
|
||||
defp ai_fields(_tab, _title, _subtitle, _page_language), do: []
|
||||
@@ -309,7 +326,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
reference_list: reference_list
|
||||
}
|
||||
rescue
|
||||
_error ->
|
||||
error ->
|
||||
log_overlay_warning("delete media details for #{media_id}", error)
|
||||
|
||||
%{
|
||||
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Media"),
|
||||
entity_name: media_id,
|
||||
@@ -330,7 +349,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
reference_list: []
|
||||
}
|
||||
rescue
|
||||
_error ->
|
||||
error ->
|
||||
log_overlay_warning("delete tag details", error)
|
||||
|
||||
%{
|
||||
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
|
||||
entity_name: "tag",
|
||||
@@ -367,7 +388,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
message: BDS.Gettext.lgettext(page_language, "ui", "Cannot be undone.")
|
||||
}
|
||||
rescue
|
||||
_error ->
|
||||
error ->
|
||||
log_overlay_warning("merge tag details for project #{project_id}", error)
|
||||
|
||||
%{
|
||||
target: "tag",
|
||||
count: 1,
|
||||
@@ -391,4 +414,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
|> String.replace(~r/[^a-z0-9]+/u, "-")
|
||||
|> String.trim("-")
|
||||
end
|
||||
|
||||
defp log_overlay_warning(context, error) do
|
||||
Logger.warning("overlay component fallback for #{context}: #{Exception.message(error)}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule BDS.ImportAnalysis do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
require Logger
|
||||
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Posts.Post
|
||||
@@ -25,7 +26,12 @@ defmodule BDS.ImportAnalysis do
|
||||
wxr_data = WxrParser.parse_file(wxr_file_path)
|
||||
{:ok, build_report(project_id, wxr_data, wxr_file_path, uploads_folder_path, on_progress)}
|
||||
rescue
|
||||
error -> {:error, %{message: Exception.message(error)}}
|
||||
error ->
|
||||
Logger.warning(
|
||||
"import analysis failed project_id=#{project_id} file=#{wxr_file_path}: #{Exception.message(error)}"
|
||||
)
|
||||
|
||||
{:error, %{message: Exception.message(error)}}
|
||||
end
|
||||
|
||||
defp build_report(project_id, wxr_data, wxr_file_path, uploads_folder_path, on_progress) do
|
||||
@@ -459,7 +465,7 @@ defmodule BDS.ImportAnalysis do
|
||||
value
|
||||
end
|
||||
rescue
|
||||
_error -> nil
|
||||
_error in [ArgumentError] -> nil
|
||||
end
|
||||
|
||||
defp year_from(value) when is_binary(value) do
|
||||
@@ -491,7 +497,12 @@ defmodule BDS.ImportAnalysis do
|
||||
try do
|
||||
callback.(step, detail)
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.warning(
|
||||
"import analysis progress callback failed step=#{inspect(step)}: #{Exception.message(error)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
:ok
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
defmodule BDS.ImportExecution do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.Media
|
||||
alias BDS.Metadata
|
||||
alias BDS.Posts
|
||||
@@ -118,7 +120,9 @@ defmodule BDS.ImportExecution do
|
||||
{:error, reason} -> {:error, %{message: format_import_error(reason)}}
|
||||
end
|
||||
rescue
|
||||
error -> {:error, %{message: Exception.message(error)}}
|
||||
error ->
|
||||
Logger.warning("import execution failed project_id=#{project_id}: #{Exception.message(error)}")
|
||||
{:error, %{message: Exception.message(error)}}
|
||||
end
|
||||
|
||||
defp execute_taxonomies(category_items, tag_items, project_id, result, on_progress, started_at) do
|
||||
@@ -363,9 +367,19 @@ defmodule BDS.ImportExecution do
|
||||
try do
|
||||
Media.link_media_to_post(media_id, post_id)
|
||||
rescue
|
||||
_ -> :ok
|
||||
error ->
|
||||
Logger.warning(
|
||||
"import execution failed to link media_id=#{media_id} post_id=#{post_id}: #{Exception.message(error)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
catch
|
||||
_, _ -> :ok
|
||||
kind, reason ->
|
||||
Logger.warning(
|
||||
"import execution failed to link media_id=#{media_id} post_id=#{post_id}: #{kind}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -586,9 +600,13 @@ defmodule BDS.ImportExecution do
|
||||
defp run_repo_transaction(fun) when is_function(fun, 0) do
|
||||
Repo.transaction(fun)
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
error ->
|
||||
Logger.warning("import execution transaction failed: #{Exception.message(error)}")
|
||||
{:error, error}
|
||||
catch
|
||||
kind, reason -> {:error, {kind, reason}}
|
||||
kind, reason ->
|
||||
Logger.warning("import execution transaction failed: #{kind}: #{inspect(reason)}")
|
||||
{:error, {kind, reason}}
|
||||
end
|
||||
|
||||
defp post_item_label(item) do
|
||||
@@ -628,11 +646,20 @@ defmodule BDS.ImportExecution do
|
||||
try do
|
||||
callback.(phase, current, total, %{detail: detail, eta: eta})
|
||||
rescue
|
||||
_error ->
|
||||
error ->
|
||||
Logger.warning(
|
||||
"import execution progress callback failed phase=#{inspect(phase)}: #{Exception.message(error)}"
|
||||
)
|
||||
|
||||
try do
|
||||
callback.(phase, current, total, detail)
|
||||
rescue
|
||||
_error -> :ok
|
||||
legacy_error ->
|
||||
Logger.warning(
|
||||
"import execution legacy progress callback failed phase=#{inspect(phase)}: #{Exception.message(legacy_error)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ defmodule BDS.Persistence do
|
||||
def parse_timestamp(_value), do: nil
|
||||
|
||||
def atomic_write(path, contents) when is_binary(path) and is_binary(contents) do
|
||||
temp_path = path <> ".tmp"
|
||||
temp_path = path <> ".tmp." <> Integer.to_string(System.unique_integer([:positive]))
|
||||
|
||||
with :ok <- File.mkdir_p(Path.dirname(path)),
|
||||
:ok <- File.write(temp_path, contents),
|
||||
|
||||
8
mix.exs
8
mix.exs
@@ -22,6 +22,10 @@ defmodule BDS.MixProject do
|
||||
]
|
||||
end
|
||||
|
||||
def cli do
|
||||
[preferred_envs: [validate: :test]]
|
||||
end
|
||||
|
||||
defp deps do
|
||||
[
|
||||
{:ecto_sql, "~> 3.13"},
|
||||
@@ -49,6 +53,8 @@ defmodule BDS.MixProject do
|
||||
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
|
||||
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
|
||||
{:lazy_html, ">= 0.1.0", only: :test},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
||||
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
|
||||
]
|
||||
end
|
||||
@@ -62,7 +68,7 @@ defmodule BDS.MixProject do
|
||||
"assets.build": ["tailwind default", "esbuild default"],
|
||||
"assets.deploy": ["tailwind default --minify", "esbuild default --minify"],
|
||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
||||
validate: ["test", "dialyzer"]
|
||||
validate: ["test", "credo --strict", "deps.audit --ignore-file .mix_audit.ignore", "dialyzer"]
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
12
mix.lock
12
mix.lock
@@ -1,11 +1,13 @@
|
||||
%{
|
||||
"axon": {:hex, :axon, "0.7.0", "2e2c6d93b4afcfa812566b8922204fa022b60081e86ebd411df4db7ea30f5457", [:mix], [{:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}, {:kino_vega_lite, "~> 0.1.7", [hex: :kino_vega_lite, repo: "hexpm", optional: true]}, {:nx, "~> 0.9", [hex: :nx, repo: "hexpm", optional: false]}, {:polaris, "~> 0.1", [hex: :polaris, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1", [hex: :table_rex, repo: "hexpm", optional: true]}], "hexpm", "ee9857a143c9486597ceff434e6ca833dc1241be6158b01025b8217757ed1036"},
|
||||
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
|
||||
"bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"},
|
||||
"bumblebee": {:hex, :bumblebee, "0.6.3", "c0028643c92de93258a9804da1d4d48797eaf7911b702464b3b3dd2cc7f938f1", [:mix], [{:axon, "~> 0.7.0", [hex: :axon, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.9.0 or ~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:nx_image, "~> 0.1.0", [hex: :nx_image, repo: "hexpm", optional: false]}, {:nx_signal, "~> 0.2.0", [hex: :nx_signal, repo: "hexpm", optional: false]}, {:progress_bar, "~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: false]}, {:safetensors, "~> 0.1.3", [hex: :safetensors, repo: "hexpm", optional: false]}, {:tokenizers, "~> 0.4", [hex: :tokenizers, repo: "hexpm", optional: false]}, {:unpickler, "~> 0.1.0", [hex: :unpickler, repo: "hexpm", optional: false]}, {:unzip, "~> 0.12.0", [hex: :unzip, repo: "hexpm", optional: false]}], "hexpm", "c619197787561f8e5fb2ffba269c341654accaec9d591999b7fddd55761dd079"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"color": {:hex, :color, "0.13.0", "068110e5397ac5d3c9f97658282e0f4ab9a32468be6d7a2a91a8804e67b228d7", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "de127946869931d418bac2d82dc29feae1a8f5f729f135922fbccf0059a58ab2"},
|
||||
"complex": {:hex, :complex, "0.7.0", "695632ef9487517aa5d57edd1697801079d622414cb2e1a7cf538b1f9a50f205", [:mix], [], "hexpm", "0ee39c0803129f546e7f3f640da8f021c9e659402bf59da6f7f2c4848f068f8d"},
|
||||
"credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"},
|
||||
"date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"},
|
||||
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
||||
"dbus": {:hex, :dbus, "0.8.0", "7c800681f35d909c199265e55a8ee4aea9ebe4acccce77a0740f89f29cc57648", [:make], [], "hexpm", "a9784f2d9717ffa1f74169144a226c39633ac0d9c7fe8cb3594aeb89c827cca5"},
|
||||
@@ -27,6 +29,7 @@
|
||||
"exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
|
||||
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
|
||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||
@@ -42,6 +45,7 @@
|
||||
"luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
|
||||
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
|
||||
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||
@@ -50,7 +54,7 @@
|
||||
"nx_image": {:hex, :nx_image, "0.1.2", "0c6e3453c1dc30fc80c723a54861204304cebc8a89ed3b806b972c73ee5d119d", [:mix], [{:nx, "~> 0.4", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "9161863c42405ddccb6dbbbeae078ad23e30201509cc804b3b3a7c9e98764b81"},
|
||||
"nx_signal": {:hex, :nx_signal, "0.2.0", "e1ca0318877b17c81ce8906329f5125f1e2361e4c4235a5baac8a95ee88ea98e", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "7247e5e18a177a59c4cb5355952900c62fdeadeb2bad02a9a34237b68744e2bb"},
|
||||
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
@@ -68,7 +72,7 @@
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
|
||||
"tokenizers": {:hex, :tokenizers, "0.5.1", "b0975d92b4ee5b18e8f47b5d65b9d5f1e583d9130189b1a2620401af4e7d4b35", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5f08d97cc7f2ed3d71d370d68120da6d3de010948ccf676c9c0eb591ba4bacc9"},
|
||||
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
|
||||
"unpickler": {:hex, :unpickler, "0.1.0", "c2262c0819e6985b761e7107546cef96a485f401816be5304a65fdd200d5bd6a", [:mix], [], "hexpm", "e2b3f61e62406187ac52afead8a63bfb4e49394028993f3c4c42712743cab79e"},
|
||||
@@ -77,4 +81,6 @@
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
"xla": {:hex, :xla, "0.9.1", "cca0040ff94902764007a118871bfc667f1a0085d4a5074533a47d6b58bec61e", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "eb5e443ae5391b1953f253e051f2307bea183b59acee138053a9300779930daf"},
|
||||
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"},
|
||||
}
|
||||
|
||||
@@ -1641,6 +1641,36 @@ defmodule BDS.AITest do
|
||||
end)
|
||||
end
|
||||
|
||||
test "delete_chat_conversation rolls back message deletion when the conversation delete fails" do
|
||||
previous_hook = Application.get_env(:bds, :chat_delete_conversation_test_hook)
|
||||
|
||||
on_exit(fn ->
|
||||
if is_nil(previous_hook) do
|
||||
Application.delete_env(:bds, :chat_delete_conversation_test_hook)
|
||||
else
|
||||
Application.put_env(:bds, :chat_delete_conversation_test_hook, previous_hook)
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, conversation} = BDS.AI.start_chat(%{title: "Rollback Chat", model: "gpt-4o-mini"})
|
||||
|
||||
message =
|
||||
Repo.insert!(%BDS.AI.ChatMessage{
|
||||
conversation_id: conversation.id,
|
||||
role: :user,
|
||||
content: "keep me",
|
||||
created_at: Persistence.now_ms()
|
||||
})
|
||||
|
||||
Application.put_env(:bds, :chat_delete_conversation_test_hook, fn _conversation_id ->
|
||||
{:error, :forced_failure}
|
||||
end)
|
||||
|
||||
assert {:error, :forced_failure} = BDS.AI.delete_chat_conversation(conversation.id)
|
||||
assert Repo.get(BDS.AI.ChatConversation, conversation.id)
|
||||
assert Repo.get(BDS.AI.ChatMessage, message.id)
|
||||
end
|
||||
|
||||
test "non-stat chat tools expose concrete project data" do
|
||||
{:ok, project} = create_project_fixture("Concrete Tools")
|
||||
%{post: post, media: media} = seed_project_content(project.id)
|
||||
|
||||
@@ -224,6 +224,30 @@ defmodule BDS.ImportAnalysisTest do
|
||||
assert_received {:analysis_progress, "Discovering macros...", nil}
|
||||
end
|
||||
|
||||
import ExUnit.CaptureLog
|
||||
|
||||
test "analyze_wxr logs and continues when the progress callback raises", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
uploads_dir = Path.join(temp_dir, "uploads")
|
||||
File.mkdir_p!(Path.join(uploads_dir, "2024/05"))
|
||||
File.write!(Path.join(uploads_dir, "2024/05/import-asset.txt"), "legacy attachment")
|
||||
|
||||
wxr_path = Path.join(temp_dir, "legacy.xml")
|
||||
File.write!(wxr_path, basic_wxr_xml())
|
||||
|
||||
log =
|
||||
capture_log(fn ->
|
||||
assert {:ok, _report} =
|
||||
ImportAnalysis.analyze_wxr(project.id, wxr_path, uploads_dir,
|
||||
on_progress: fn _step, _detail -> raise "boom" end
|
||||
)
|
||||
end)
|
||||
|
||||
assert log =~ "import analysis progress callback failed"
|
||||
end
|
||||
|
||||
defp sha256(value) do
|
||||
:sha256
|
||||
|> :crypto.hash(value)
|
||||
|
||||
@@ -112,6 +112,34 @@ defmodule BDS.ImportExecutionTest do
|
||||
assert File.exists?(Path.join(temp_dir, imported_media.file_path))
|
||||
end
|
||||
|
||||
import ExUnit.CaptureLog
|
||||
|
||||
test "execute_import logs and continues when the progress callback raises", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
uploads_dir = Path.join(temp_dir, "uploads")
|
||||
File.mkdir_p!(Path.join(uploads_dir, "2024/05"))
|
||||
File.write!(Path.join(uploads_dir, "2024/05/import-asset.txt"), "legacy attachment")
|
||||
|
||||
wxr_path = Path.join(temp_dir, "legacy.xml")
|
||||
File.write!(wxr_path, basic_wxr_xml())
|
||||
|
||||
assert {:ok, report} = ImportAnalysis.analyze_wxr(project.id, wxr_path, uploads_dir)
|
||||
|
||||
log =
|
||||
capture_log(fn ->
|
||||
assert {:ok, _result} =
|
||||
ImportExecution.execute_import(project.id, report,
|
||||
default_author: "Imported Author",
|
||||
uploads_folder_path: uploads_dir,
|
||||
on_progress: fn _phase, _current, _total, _detail -> raise "boom" end
|
||||
)
|
||||
end)
|
||||
|
||||
assert log =~ "import execution progress callback failed"
|
||||
end
|
||||
|
||||
test "execute_import skips conflicts by default and can import them with a new slug", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
|
||||
44
test/bds/mix_project_test.exs
Normal file
44
test/bds/mix_project_test.exs
Normal file
@@ -0,0 +1,44 @@
|
||||
defmodule BDS.MixProjectTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
test "validate alias runs all required quality gates" do
|
||||
aliases = BDS.MixProject.project()[:aliases]
|
||||
|
||||
assert aliases[:validate] == [
|
||||
"test",
|
||||
"credo --strict",
|
||||
"deps.audit --ignore-file .mix_audit.ignore",
|
||||
"dialyzer"
|
||||
]
|
||||
end
|
||||
|
||||
test "quality tooling deps are available in dev and test" do
|
||||
deps = BDS.MixProject.project()[:deps]
|
||||
|
||||
assert quality_dep(deps, :credo) == {:credo, "~> 1.7", [only: [:dev, :test], runtime: false]}
|
||||
|
||||
assert quality_dep(deps, :mix_audit) ==
|
||||
{:mix_audit, "~> 2.1", [only: [:dev, :test], runtime: false]}
|
||||
end
|
||||
|
||||
test "mix audit exceptions stay explicit and scoped" do
|
||||
ignore_file = File.read!(Path.expand("../../.mix_audit.ignore", __DIR__))
|
||||
|
||||
assert ignore_file =~ "GHSA-rhv4-8758-jx7v"
|
||||
refute ignore_file =~ "GHSA-628h-q48j-jr6q"
|
||||
end
|
||||
|
||||
test "validate runs in the test environment" do
|
||||
cli = BDS.MixProject.cli()
|
||||
|
||||
assert cli[:preferred_envs][:validate] == :test
|
||||
end
|
||||
|
||||
defp quality_dep(deps, app) do
|
||||
Enum.find(deps, fn
|
||||
{^app, _version} -> true
|
||||
{^app, _version, _opts} -> true
|
||||
_other -> false
|
||||
end)
|
||||
end
|
||||
end
|
||||
53
test/bds/persistence_test.exs
Normal file
53
test/bds/persistence_test.exs
Normal file
@@ -0,0 +1,53 @@
|
||||
defmodule BDS.PersistenceTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias BDS.Persistence
|
||||
alias BDS.Posts.TranslationValidation
|
||||
|
||||
test "atomic_write keeps concurrent writers intact" do
|
||||
temp_dir = Path.join(System.tmp_dir!(), "bds-persistence-#{System.unique_integer([:positive])}")
|
||||
path = Path.join(temp_dir, "shared.txt")
|
||||
payload_a = String.duplicate("alpha", 200_000)
|
||||
payload_b = String.duplicate("bravo", 200_000)
|
||||
|
||||
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||
|
||||
parent = self()
|
||||
|
||||
tasks =
|
||||
for index <- 1..12 do
|
||||
payload = if rem(index, 2) == 0, do: payload_a, else: payload_b
|
||||
|
||||
Task.async(fn ->
|
||||
send(parent, :ready)
|
||||
|
||||
receive do
|
||||
:go -> Persistence.atomic_write(path, payload)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
for _ <- tasks do
|
||||
assert_receive :ready
|
||||
end
|
||||
|
||||
Enum.each(tasks, fn task -> send(task.pid, :go) end)
|
||||
|
||||
assert Enum.map(tasks, &Task.await(&1, 15_000)) == List.duplicate(:ok, length(tasks))
|
||||
assert File.read!(path) in [payload_a, payload_b]
|
||||
end
|
||||
|
||||
test "post rebuild globs ignore atomic temp files" do
|
||||
temp_dir = Path.join(System.tmp_dir!(), "bds-persistence-glob-#{System.unique_integer([:positive])}")
|
||||
posts_dir = Path.join(temp_dir, "posts")
|
||||
|
||||
File.mkdir_p!(posts_dir)
|
||||
File.write!(Path.join(posts_dir, "entry.md"), "---\ntitle: Entry\n---\n")
|
||||
File.write!(Path.join(posts_dir, "entry.md.tmp.123"), "temp")
|
||||
|
||||
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||
|
||||
assert TranslationValidation.list_matching_files(posts_dir, "*.md") ==
|
||||
[Path.join(posts_dir, "entry.md")]
|
||||
end
|
||||
end
|
||||
@@ -85,6 +85,32 @@ defmodule BDS.Repo.BootstrapTest do
|
||||
assert repo_config[:busy_timeout] == 15_000
|
||||
end
|
||||
|
||||
test "prod runtime repo config defaults to the same pool size as dev" do
|
||||
config_path = Path.expand("../../../config/runtime.exs", __DIR__)
|
||||
database_path = Path.join(System.tmp_dir!(), "bds-runtime-#{System.unique_integer([:positive])}.db")
|
||||
|
||||
previous_pool_size = System.get_env("POOL_SIZE")
|
||||
previous_database_path = System.get_env("BDS_DATABASE_PATH")
|
||||
|
||||
System.delete_env("POOL_SIZE")
|
||||
System.put_env("BDS_DATABASE_PATH", database_path)
|
||||
|
||||
on_exit(fn ->
|
||||
restore_env("POOL_SIZE", previous_pool_size)
|
||||
restore_env("BDS_DATABASE_PATH", previous_database_path)
|
||||
File.rm_rf(Path.dirname(database_path))
|
||||
end)
|
||||
|
||||
config = Config.Reader.read!(config_path, env: :prod)
|
||||
|
||||
repo_config =
|
||||
config
|
||||
|> Keyword.fetch!(:bds)
|
||||
|> Keyword.fetch!(BDS.Repo)
|
||||
|
||||
assert repo_config[:pool_size] == 5
|
||||
end
|
||||
|
||||
defmodule RepoConfigBackup do
|
||||
def put_env do
|
||||
Process.put({__MODULE__, :temp_repo_config}, Application.get_env(:bds, TempRepo))
|
||||
@@ -97,4 +123,7 @@ defmodule BDS.Repo.BootstrapTest do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp restore_env(key, nil), do: System.delete_env(key)
|
||||
defp restore_env(key, value), do: System.put_env(key, value)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user