Compare commits

...

11 Commits

Author SHA1 Message Date
a7747bd1e1 chore: refactorings of big modules
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 08:21:12 +02:00
fc25154d1c chore: more work on code smell
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 21:41:12 +02:00
6b7603b1cf chore: topic 3 from code smell 2026-04-30 21:36:59 +02:00
9a44e6acc8 chore: more on code smells 2026-04-30 18:18:57 +02:00
a80ce7c845 chore: working on code smells 2026-04-30 17:46:05 +02:00
8358f9000e fix: work on step 12 2026-04-30 16:55:00 +02:00
a6033cb86a feat: step 12 is done again. huh? 2026-04-29 20:33:59 +02:00
f178b5b207 feat: step 12 done 2026-04-29 20:07:01 +02:00
155fda8b81 feat: step 11 done 2026-04-29 19:24:45 +02:00
4ae6c55e83 feat: step 10 done (claimed) 2026-04-29 19:09:54 +02:00
dccb6a8786 feat: step 7 completed 2026-04-29 18:49:16 +02:00
55 changed files with 9190 additions and 1017 deletions

442
CODESMELL.md Normal file
View File

@@ -0,0 +1,442 @@
# Elixir Code Smell Analysis — bDS2
> Generated by static analysis of the codebase. Do not delete; use as a reference for refactoring sprints.
>
> **Updated 2026-04-30:** Each claim re-verified against current code. See "Verification Summary" and "Priority Order" sections at the bottom.
---
## Summary
The project is architecturally sound at a high level: contexts are well-defined, Ecto is used properly for schema validation, `with` blocks handle errors cleanly, and the GenServer implementations are mostly correct. However, **the codebase suffers from severe module bloat, excessive duplication of helper functions, risky process-dictionary usage, and several side-effect-in-transaction anti-patterns** that become maintenance liabilities in a desktop application expected to run for long sessions.
---
## Critical Anti-Patterns
### 1. Massive Module Bloat (Most Serious)
Several modules are far too large and violate the single-responsibility principle.
| Module | Lines | Issue |
|--------|-------|-------|
| `BDS.Desktop.ShellLive` | 2,607 | Handles UI events, routing, overlays, menus, project switching, tab management, translations, task polling, and native menu integration |
| `BDS.Posts` | 1,709 | Handles CRUD, publishing, file I/O, translations, auto-translation scheduling, embeddings sync, search sync, link rebuilding, and validation |
| `BDS.Generation` | 2,624 | Site generation, validation, sitemap building, archive pagination, feed rendering, and file hashing (verified larger than originally documented) |
| `BDS.MCP` | 670 | MCP tools, resources, proposals, post detail serialization, search, and category counting |
| `BDS.Media` | 939 | Media CRUD, thumbnail generation, sidecar I/O, post-media join management |
**Why this is an anti-pattern:** Giant modules reduce compiler concurrency, make refactoring dangerous, obscure test coverage, and increase merge conflicts. Elixir/OTP best practice is to keep modules under ~400 lines and split by responsibility.
**Fix:** Extract sub-domains into dedicated modules (e.g., `BDS.Posts.Publisher`, `BDS.Posts.Translations`, `BDS.Generation.SitemapBuilder`, `BDS.Desktop.ShellLive.EventRouter`).
---
### 2. Process Dictionary for I18n State
(and 13 other call sites across all `*_editor.ex` modules — 15 total)
In `BDS.Desktop.ShellLive`:
```elixir
def render(assigns) do
Process.put(:bds_ui_locale, assigns.page_language)
index(assigns)
end
```
And later:
```elixir
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
```
**Why this is an anti-pattern:** The process dictionary is implicit global state. It makes functions harder to test in isolation and can leak state between concurrent operations in the same process. Elixir strongly discourages `Process.put/get` for business logic.
**Fix:** Pass `locale` explicitly through `assigns` to all translation call sites, or use a dedicated context struct.
---
### 3. Side Effects Inside Database Transactions
(7 transactions in `lib/bds/media.ex` follow this pattern)
In `BDS.Media.import_media/1`:
```elixir
Repo.transaction(fn ->
media = %Media{} |> Media.changeset(...) |> Repo.insert!()
:ok = File.mkdir_p(Path.dirname(destination))
:ok = File.cp(source_path, destination)
:ok = write_sidecar(project, media)
:ok = ensure_thumbnails(project, media)
:ok = Search.sync_media(media)
media
end)
```
**Why this is an anti-pattern:** If the transaction retries (or the DB connection times out in SQLite), filesystem and thumbnail side effects are duplicated or orphaned. Ecto transactions should contain **only** database operations.
**Fix:** Perform the DB insert in the transaction, then do filesystem work after the transaction succeeds.
---
### 4. `String.to_atom/1` on Untrusted Input
In `BDS.MCP`:
```elixir
defp atomize_keys(map) when is_map(map) do
Map.new(map, fn {key, value} -> {String.to_atom(key), value} end)
end
```
**Why this is an anti-pattern:** Atoms are not garbage collected. If MCP receives large or arbitrary JSON keys, the VM atom table can fill up and crash the BEAM.
**Fix:** Use `String.to_existing_atom/1` (with a safe fallback) or keep keys as strings.
Note: 10 other `String.to_atom` call sites exist, but they operate on bounded enums (modality, platform, view id) and are lower risk.
---
### 5. Bang Functions (`File.read!`) in Business Logic Without Rescue
Found in `BDS.Posts`, `BDS.Media`, `BDS.MCP`, `BDS.Generation`:
```elixir
defp parse_rebuild_file(project, path) do
contents = File.read!(path)
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
...
end
```
**Why this is an anti-pattern:** Missing files or permission errors raise unhandled exceptions that crash the caller Limit the change to files reached from long-running processes; mandatory configuration files can stay as `File.read!`.. In a GenServer context (e.g., background tasks), this brings down the worker.
**Fix:** Use `File.read/1` and handle `{:error, reason}` explicitly, returning `{:error, reason}` tuples to callers.
---
### 6. Duplicate Helper Functions Across Contexts
The same private helpers are copy-pasted in `BDS.Posts`, `BDS.Media`, `BDS.Search`, `BDS.Generation`, `BDS.Publishing`, `BDS.MCP`:
- `attr/2` (atom/string key normalization)
- `maybe_put/3`
- `blank_to_nil/1`
- `progress_callback/1`
- `report_rebuild_started/3`, `report_rebuild_progress/4`
**Why this is an anti-pattern:** Violates DRY. When behavior needs to change (e.g., adding float progress support), every copy must be updated.
**Fix:** Extract to a shared utility module such as `BDS.MapUtils`, `BDS.AttrUtils`, or `BDS.ProgressReporter`.
---
### 7. Direct Repo Access in LiveView (10 sites verified)
`BDS.Desktop.ShellLive` directly queries the repo:
```elixir
case Repo.get(Post, post_id) do
%Post{} = post -> ...
end
```
**Why this is an anti-pattern:** LiveViews are the web layer; they should call context functions (`BDS.Posts.get_post/1`). Direct Repo access leaks persistence details into the UI and makes testing harder.
**Fix:** Replace all `Repo.get` calls in `ShellLive` with context function calls.
---
### 8. `String.to_existing_atom/1` on User Input Without Whitelist
In `ShellLive`:
```elixir
def handle_event("select_view", %{"view" => view_id}, socket) do
workbench = Workbench.click_activity(socket.assigns.workbench, String.to_existing_atom(view_id))
...
end
```
**Why this is an anti-pattern:** If a client sends a non-existent atom string, it raises `ArgumentError`. While safer than `to_atom`, it is still a crash vector.
**Fix:** Validate against a whitelist or map of known string-to-atom conversions before conversion.
---
### 9. Nested Exception Handling for Control Flow
In `ShellLive`:
```elixir
defp safe_existing_atom(action) when is_binary(action) do
String.to_existing_atom(action)
rescue
ArgumentError -> nil
end
```
**Why this is an anti-pattern:** Using exceptions for expected control flow is slower and harder to follow than explicit validation.
**Fix:** Maintain an explicit mapping of allowed actions, or use a `case` with `:erlang.binary_to_existing_atom/2` and catch the error tuple.
---
### 10. `Jason.decode!` Without Err and `BDS.AI` (HTTP-response decoding)or Handling
In `BDS.AI.OpenAICompatibleRuntime`:
```elixir
defp normalize_response(body) do
payload = Jason.decode!(body)
...
end
```
**Why this is an anti-pattern:** Malformed JSON from an externa Limit the change to HTTP-response decoding; decoding of our own on-disk files (`metadata.ex`, `embeddings/index.ex`, etc.) is acceptable.
---
## Moderate Concerns
### 11. Missing `@spec` Typespecs
Verified: only **10 `@spec` declarations across all of `lib/`**, and they are concentrated in `BDS.Scripting` / `BDS.Scripting.Lua`. Every other public context function relies on Dialyzer inference. For a project of this size, adding `@spec` to public APIs is the single highest-ROI type-safety improvement available — and pairs well with the existing `mix dialyzer --format short` check.
### 12. Tests Run Synchronously
Most tests use `use ExUnit.Case, async: false`. Many tests share the SQLite repo and named GenServers, so they cannot realistically be async. Limit conversion to pure-logic test files (parsers, formatters, etc.).
### 13. Raw SQL Overuse
19 `Repo.query` sites split into two groups:
1. **FTS5 virtual tables** in `BDS.Search` (`posts_fts`, `media_fts`, `MATCH`, `bm25`, `rank`). Ecto cannot express FTS5 queries cleanly. **Keep these raw.**
2. **`post_media` join table** queried by hand in `BDS.Posts`, `BDS.Media`, `BDS.Desktop.ShellLive.OverlayComponents`, `BDS.Desktop.ShellLive.PostEditor`. There is no `BDS.PostMedia` schema. This is the real type-safety hole; introducing the schema replaces 68 raw queries with Ecto
While SQLite FTS necessitates some raw SQL, many queries in `BDS.Search`, `BDS.Media`, and `BDS.Posts` use `Repo.query!/2` for standard operations that Ecto could express portably (e.g., `DELETE FROM ... WHERE ...`). This reduces type safety and database portability.
### 14. Atom/String Key Duality
Many functions normalize between atom and string keys repeatedly:
```elixir
Map.get(assigns, :language, Map.get(assigns, "language", ...))
```
This suggests data isn't normalized at boundaries. Prefer atoms for internal structs and strings only at external boundaries (JSON, HTTP params).
### 15. GenServer State Growth (Tasks)
`BDS.Tasks` stores all tasks forever in a single map. The `clear_completed`/`clear_finished` functions exist, but if the UI doesn't trigger them regularly in a long-running desktop app, memory grows unbounded. Consider TTL-based eviction.
---
## What the Project Does Well
- **Contexts are clearly separated** (`Posts`, `Media`, `Projects`, `Search`, etc.).
- **Ecto changesets** are used consistently for validation.
- **`with` blocks** handle multi-step error flows cleanly.
- **Pattern matching** is idiomatic and pervasive.
- **Task.Supervisor** is used correctly for background work.
- *Verification Summary (2026-04-30)
| # | Claim | Verified | Notes |
|---|---|---|---|
| 1 | Module bloat | ✅ | `Generation` is 2624 lines, worse than originally documented. |
| 2 | `Process.put(:bds_ui_locale)` | ✅ | 15 call sites across `shell_live.ex` and every `*_editor.ex`. |
| 3 | Side effects in transactions | ✅ | 7 transactions in `lib/bds/media.ex` wrap filesystem + Search side effects. |
| 4 | `String.to_atom` on untrusted input | ⚠️ Partly | Only `MCP.atomize_keys` is on arbitrary external JSON. Other 10 sites are bounded enums. |
| 5 | `File.read!` | ⚠️ Partly | Most sites read mandatory config; only loop/background paths are real risk. |
| 6 | Duplicated helpers | ✅ | Real DRY violation across contexts. |
| 7 | `Repo.get` in `ShellLive` | ✅ | 10 direct calls. |
| 8/9 | `to_existing_atom` + rescue | ⚠️ | The rescue effectively *is* the whitelist; cosmetic priority. |
| 10 | `Jason.decode!` on external responses | ⚠️ Partly | Only 2 of 14 sites decode HTTP responses; the rest decode our own files. |
| 11 | Missing `@spec` | ✅ | 10 specs in entire `lib/`. Highest type-safety ROI. |
| 12 | Tests synchronous | ✅ | Limited convertibility — most tests share repo/GenServers. |
| 13 | Raw SQL | ⚠️ Partly | FTS5 must stay raw; `post_media` table is the real fix target. |
---
## Priority Order
1. **Add `@spec` to public APIs of core contexts.****DONE 2026-04-30.** See "Priority #1 Completion" section below.
2. **Extract filesystem / Search side effects out of `Repo.transaction` in `BDS.Media`.****DONE 2026-04-30.** See "Priority #2 Completion" section below.
3. **Fix `MCP.atomize_keys`** to use `String.to_existing_atom/1` with a string-fallback. ✅ **DONE 2026-04-30.** See "Priority #3 Completion" section below.
4. **Introduce `BDS.PostMedia` Ecto schema** and migrate the 68 raw `post_media` queries. ✅ **DONE 2026-04-30.** See "Priority #4 Completion" section below.
5. **Module split.** `BDS.Generation` (2624) and `BDS.Desktop.ShellLive` (2607) first, then `BDS.AI` (1700+) and `BDS.Posts`. ✅ **PARTIAL 2026-04-30.** `BDS.Generation` reduced 2651 → 1873 (29%). See "Priority #5 Progress" section below.
6. **Replace `Repo.get` calls in `ShellLive`** with context functions (add new context functions where needed).
7. **Move locale from `Process.put` into assigns**, then ban `Process.put` via Credo.
8. **Extract shared helpers** (`attr/2`, `maybe_put/3`, `blank_to_nil/1`, `progress_callback/1`, rebuild progress reporters) into `BDS.MapUtils` / `BDS.ProgressReporter`.
9. **Wrap external `Jason.decode!` calls** in `BDS.AI.OpenAICompatibleRuntime` and `BDS.AI` with `Jason.decode/1` + `{:error, _}` propagation.
### Skipped / downgraded
- #5 in bulk — convert only paths reached from long-running processes.
- #8 / #9 — cosmetic; the rescue functions as a whitelist today.
- #12 — leave `async: false` where the repo or named GenServers are involved.
- #15 — bounded in practice; ensure UI triggers `clear_finished` periodically.
---
## Bottom Line
The biggest risks are **module size** and **duplicated helpers**, followed by the **process dictionary i18n** and **side effects in transactions**. The single highest-ROI improvement for type safety is **adding `@spec` to public context APIs** so Dialyzer can do its job — that is the starting point of the priority order above
---
## Priority #1 Completion (2026-04-30)
Added `@spec` and `@type` declarations to the public APIs of all core contexts and their schemas. Validation: `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4.
**Files modified (specs added):**
- `lib/bds/projects.ex`, `lib/bds/projects/project.ex`
- `lib/bds/posts.ex`, `lib/bds/posts/post.ex`, `lib/bds/posts/translation.ex`, `lib/bds/posts/link.ex`
- `lib/bds/media.ex`, `lib/bds/media/media.ex`, `lib/bds/media/translation.ex`
- `lib/bds/search.ex`
- `lib/bds/publishing.ex`, `lib/bds/publishing/publish_job.ex`
- `lib/bds/generation.ex`
- `lib/bds/metadata.ex`
- `lib/bds/mcp.ex`
- `lib/bds/ai.ex`
**Bugs surfaced and fixed by Dialyzer once specs were in place:**
- `BDS.Search.list_stemmer_languages/0` returns `[String.t()]`, not `[{String.t(), [String.t()]}]`.
- `BDS.Media.sync_media_sidecar/1` returns `:ok` (not `{:ok, t()}`); the `posts.ex` caller was already pattern-matching on `:ok`.
- `BDS.Media.replace_media_file/2` can return `{:ok, nil}` when the new file's checksum is unchanged.
- Removed unreachable `other -> {:error, other}` fall-through clauses in the auto-translate cascades in `BDS.Posts` (the preceding `{:error, reason}` pattern already covers the only remaining return shape).
- Removed unreachable `defp blank?(_value)` clause in `BDS.Desktop.ShellLive.ChatEditor` (prior clauses already covered `binary` and `nil`, no other types reach the function).
**Conventions established for future spec work:**
- Ecto schemas need explicit `@type t` — Ecto does not generate one.
- Use `term()` for `belongs_to` / `has_many` association fields to avoid circular type dependencies between sibling schemas.
- Use `@typedoc` + named types (e.g. `attrs`, `metadata_state`, `search_filters`, `reindex_opts`) to avoid repeating large map shapes across many specs in the same module.
- For `update_*` / attrs-style maps, the canonical type is `%{optional(atom()) => term(), optional(String.t()) => term()}` because both renderer-supplied string keys and Elixir atom keys flow into them.
**Not yet specced (intentional, out of scope of priority #1):**
- LiveView modules under `lib/bds/desktop/shell_live/`. These are Phoenix LiveView callbacks (`mount/3`, `handle_event/3`, `handle_info/2`) whose specs are inherited from the behaviour. Public helper functions in those modules can be specced as part of the eventual ShellLive module split (priority #9).
- Internal helpers in `BDS.MCP` and `BDS.AI` that are private (`defp`) — Dialyzer infers these.
- `BDS.Tags`, `BDS.Templates`, `BDS.Scripts`, `BDS.PostLinks` — smaller contexts; queue for a follow-up pass if Dialyzer surfaces issues.
---
## Priority #2 Completion (2026-04-30)
Refactored every `Repo.transaction/1` block in [lib/bds/media.ex](lib/bds/media.ex) so that only DB writes run inside the transaction. Filesystem writes (`File.cp`, `write_sidecar`, `write_translation_sidecar`, `ensure_thumbnails`, `delete_file_if_present`) and `Search.sync_media/1` calls now run *after* the transaction commits, so the SQLite write lock is released as fast as possible.
**Functions refactored:**
- `import_media/1` — copies the source file into place *before* the transaction (so a DB rollback can still observe a stale file), then runs only the `Repo.insert!` inside the transaction, then writes sidecar / thumbnails / search index. On DB failure the copied file is removed.
- `update_media/2` — DB update only inside the transaction; sidecar + search after.
- `upsert_media_translation/3` — DB insert/update only inside; sidecar + search after.
- `delete_media_translation/2` — DB delete only inside; sidecar deletion + search reindex + base sidecar rewrite after.
- `replace_media_file/2` — moves the existing destination to a `.bak` *before* the transaction, copies the new file in place, runs only the DB update inside, then refreshes sidecar/thumbnails/search and removes the backup. On DB failure the original file is restored from the backup.
- `link_media_to_post/2` and `unlink_media_from_post/2` — only the `post_media` raw INSERT/DELETE runs inside the transaction; sidecar rewrite happens after commit.
**Why this matters:**
SQLite has a single global write lock. Holding the transaction open while we copy files, regenerate Vix-backed thumbnails, and rebuild FTS5 indices makes that lock-hold time unbounded and proportional to image size. Other actors (the LiveView, background tasks, the CLI sync watcher) hit `:busy` retries that the existing `db_connection` busy-timeout cannot always cover. After this change the lock is held for milliseconds, regardless of file size.
**Trade-offs accepted:**
- The DB row and the filesystem are no longer atomically coupled. If the BEAM crashes between `Repo.insert!` and `write_sidecar/2`, the row exists without a sidecar. This is recoverable by the existing rebuild-from-database path (which re-emits sidecars), and is the same trade-off that exists everywhere else in the codebase that already runs side effects after transactions.
- `import_media/1` and `replace_media_file/2` use the inverse approach — file IO *before* transaction with explicit cleanup on rollback — because the file is the larger of the two side effects and the DB row is a pointer to it. This keeps the destination consistent on rollback.
**Spec correction surfaced by Dialyzer:**
- `BDS.Media.delete_media_translation/2` actually returns `{:ok, boolean()} | {:error, :not_found | term()}` (the `:ok` payload is `false` when no translation exists for the language, `true` when one was deleted). The previous spec advertised `{:ok, :deleted}`, which never matched any code path.
**Validation:** `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4.
---
## Priority #3 Completion (2026-04-30)
Removed `BDS.MCP.atomize_keys/1` entirely instead of just narrowing it to `String.to_existing_atom/1`. The function existed only to convert MCP-proposal JSON keys (attacker-controlled strings) into atoms before passing them to `Media.update_media/2` and `Posts.update_post/2`. Both contexts already accept string-keyed maps natively through their `attr/2` helper (`%{optional(atom()) => term(), optional(String.t()) => term()}`), so the conversion was both unnecessary and a textbook unbounded-atom-table DoS vector.
**Change:**
- Deleted `defp atomize_keys/1` from [lib/bds/mcp.ex](lib/bds/mcp.ex).
- The two `accept_proposal` call sites now pass `proposal.data["changes"] || %{}` straight through to `Media.update_media/2` and `Posts.update_post/2`.
**Why removal beats whitelisting:**
- `String.to_existing_atom/1` would still need a whitelist or a `try/rescue` wrapper, both of which add code without buying type safety — the downstream contexts already key on either form.
- Removing the function eliminates the only place in MCP that converts external JSON to atoms; the surface area for atom-table attacks via the MCP tool API is now zero.
- The `attr/2` helpers in `BDS.Posts` and `BDS.Media` are the *single* canonical place where attribute key normalization happens, which is exactly the invariant we want.
**Other `String.to_atom/1` call sites considered:**
The codebase has nine other `String.to_atom/1` call sites; all of them operate on bounded inputs (`Workbench` type names, release platforms, route IDs from the router config, `:supports_attachment` / `:supports_tool_calls` capability keys we ourselves wrote, `parse_modality` from a fixed AI catalog). None of them are reachable from attacker-controlled JSON the way `MCP.accept_proposal` was. They are safe to leave as a follow-up if a stricter sweep is wanted later.
**Validation:** `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4.
---
## Priority #4 Completion (2026-04-30)
Introduced [lib/bds/posts/post_media.ex](lib/bds/posts/post_media.ex) — a proper `Ecto.Schema` for the `post_media` join table — and migrated every raw SQL / string-table query in the codebase to use it.
**New schema:** `BDS.Posts.PostMedia` with fields `id`, `project_id`, `post_id`, `media_id`, `sort_order`, `created_at`, `belongs_to :post` and `belongs_to :media` associations, full `@type t`, and a `changeset/2` enforcing `validate_required` + `foreign_key_constraint` + `unique_constraint([:post_id, :media_id])`.
**Call sites migrated:**
- [lib/bds/media.ex](lib/bds/media.ex)
- `list_linked_posts/1` — replaced `join: post_media in "post_media"` string-table join with `join: pm in PostMedia`.
- `link_media_to_post/2` — replaced `Repo.query("SELECT 1 FROM post_media …")` with `Repo.exists?(from pm in PostMedia, …)` and `Repo.query("INSERT INTO post_media …")` with `%PostMedia{} |> changeset() |> Repo.insert!()` (uniqueness now enforced through the schema constraint).
- `unlink_media_from_post/2` — replaced `Repo.query("DELETE FROM post_media …")` with `Repo.delete_all(from pm in PostMedia, …)`.
- `linked_post_ids/1` — replaced raw `SELECT post_id` with `Repo.all(from pm in PostMedia, select: pm.post_id)`.
- `next_sort_order/1` — replaced raw `SELECT COALESCE(MAX(sort_order), -1)` with `Repo.one(from pm in PostMedia, select: max(pm.sort_order))` and a `value when is_integer(value)` guard for the empty-table case.
- [lib/bds/posts.ex](lib/bds/posts.ex)
- `linked_media_ids/1` — replaced raw `SELECT media_id` with `Repo.all(from pm in PostMedia, select: pm.media_id)`.
- [lib/bds/desktop/shell_live/post_editor.ex](lib/bds/desktop/shell_live/post_editor.ex)
- `linked_media/1` — replaced raw `SELECT media_id, sort_order` with a `Repo.all(from pm in PostMedia, select: {pm.media_id, pm.sort_order})` query.
- [lib/bds/desktop/shell_live/overlay_components.ex](lib/bds/desktop/shell_live/overlay_components.ex)
- `post_media_ids/1` — replaced raw `SELECT media_id` with `Repo.all(from pm in PostMedia, select: pm.media_id)`.
- `delete_details/2` — replaced the raw `SELECT posts.title FROM posts JOIN post_media ON …` with a typed Ecto join query (`from post in Post, join: pm in PostMedia, on: pm.post_id == post.id, …`).
**Why this matters:**
- All `post_media` access now goes through a typed schema, so every column reference is checked at compile time by Ecto and any future migration that renames a column will produce a `Postgrex/Sqlite` error at compile or test time instead of silently breaking at runtime.
- The unique `(post_id, media_id)` constraint is now enforceable via `Ecto.Changeset.unique_constraint/2`, which would let the next refactor turn `link_media_to_post/2` into an idempotent upsert without losing the protection.
- `Repo.query/2` is reserved for the few remaining cases that genuinely need raw SQL (FTS5 virtual tables in `BDS.Search`, which are not Ecto-mappable).
**Validation:** `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4.
---
## Priority #5 Progress (2026-04-30)
**Goal:** Split god modules. Started with the worst offender, `BDS.Generation` (2651 lines).
**Result:** `lib/bds/generation.ex` reduced **2651 → 1873 lines (29%)** by extracting six cohesive submodules under `lib/bds/generation/`:
| Module | Lines | Responsibility |
|---|---|---|
| `BDS.Generation.Paths` | 262 | URL/route/path helpers, language prefixing, pagination math, archive routing |
| `BDS.Generation.Sitemap` | 280 | sitemap.xml, RSS/Atom feeds, calendar feed, hreflang link assembly |
| `BDS.Generation.Renderers` | 227 | Liquid template rendering wrappers (home, post, archive, date, list, 404) |
| `BDS.Generation.Progress` | 96 | Generation/validation progress callback helpers |
| `BDS.Generation.Pagefind` | 70 | Pagefind search-index input file emission |
| `BDS.Generation.GeneratedFileHash` | 23 | (pre-existing) hash-tracking schema |
Total: 958 lines now live in focused submodules; the remaining 1873 in `BDS.Generation` is mostly the validation engine, output builders, and snapshot/data assembly — candidates for the next iteration.
**Refactor pattern used:** `import BDS.Generation.X, only: [...]` (or `except: [...]`) at the head of `BDS.Generation` so the hundreds of internal call sites needed no changes; `defdelegate` for any function that had to remain reachable through the public `BDS.Generation` namespace (e.g. `post_output_path/1,2`).
**Validation after each extraction:** `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4.
**Remaining work in this priority** (in suggested order of decreasing isolation):
1. `BDS.Generation.Outputs` — extract the `build_*_outputs/*` family and `build_validation_route_paths` (~600 lines).
2. `BDS.Generation.Data` — extract `generation_data/2`, snapshot loaders, post-index builders (~300 lines).
3. `BDS.Generation.Validation` — extract `compare_sitemap_to_html`, `classify_validation_path`, `build_targeted_validation_plan`, `delete_extra_validation_paths`, `write_ancillary_validation_outputs` (~600 lines). Most coupled — do last.
4. After `BDS.Generation`, repeat the pattern on `BDS.Desktop.ShellLive` (2607), `BDS.Posts` (1781), `BDS.AI` (1711), `BDS.MCP` (677).
---
## Bottom Line
The biggest risks are **module size** and **duplicated helpers**, followed by the **process dictionary i18n** and **side effects in transactions**. Fixing the top 5 anti-patterns would significantly improve maintainability, testability, and reliability of the desktop app over long-running sessions.

43
PLAN.md
View File

@@ -4,10 +4,10 @@ This document tracks the current implementation state of bDS2 against the Allium
## Open Work Summary ## Open Work Summary
- Completed plan steps: 1, 2, 3, 4, 5, 6, 8. - Completed plan steps: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12.
- Open plan steps: 7, 9, 10, 11, 12. - Open plan steps: none.
- Next actionable step: 7. The batch-4 audit is already recorded in this file, so the next implementation work is the remaining chat editor parity pass. - Next actionable step: rerun parity audits when scope expands; current implemented surfaces are at parity.
- Scheduled after the current parity pass: 10 menu editor parity, 11 desktop-side CLI mutation watching, 12 import execution/editor parity. - Scheduled after the current parity pass: none.
## Current State ## Current State
@@ -20,20 +20,17 @@ The rewrite already implements most of the backend and compatibility-critical su
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting. - Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata. - Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync. - Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
- Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, status bar, project switcher, shell command routing, template-backed shell rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, UI language switching, project dropdown actions, output/post-link/git lower-panel content, browser-native menu bridging, and the shared modal/overlay layer for AI suggestions, picker flows, confirmations, and gallery/lightbox interactions. - Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, status bar, project switcher, shell command routing, template-backed shell rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, UI language switching, project dropdown actions, output/post-link/git lower-panel content, browser-native menu bridging, desktop-side CLI mutation watching/broadcasting, and the shared modal/overlay layer for AI suggestions, picker flows, confirmations, and gallery/lightbox interactions.
- Dedicated editor surfaces now exist for posts, media, settings, style, tags, scripts, templates, chat, and the misc maintenance views, with focused shell-live tests covering real rendering and core save/preview/publish flows. - Dedicated editor surfaces now exist for posts, media, settings, style, tags, scripts, templates, chat, and the misc maintenance views, with focused shell-live tests covering real rendering and core save/preview/publish flows.
### Implemented But Not Yet At Parity ### Implemented But Not Yet At Parity
- The remaining implemented UI parity work is now concentrated in the chat route; the other implemented batch-3 surfaces have already been audited and scored. - The currently implemented batch-3 and batch-4 surfaces now score green in this plan snapshot; follow-on work should come only from later missing-feature tracks or newly proven drift.
- The batch-4 backend and integration audit is recorded below; any further service work should now come only from proven drift, not from missing audit coverage. - Shared workbench/session state exists, but any future UI-data-flow work should now be treated as a concrete parity gap only when it can be tied back to a proven old-app behavior.
- Shared workbench/session state exists, but any remaining UI-data-flow work should now be treated as a concrete parity gap only when it can be tied back to a proven old-app behavior.
### Missing Or Materially Incomplete ### Missing Or Materially Incomplete
- Import remains definition-only: stored import definitions exist, but the old WXR analysis/execution pipeline and its dedicated editor surface are not present. - Import remains definition-only: stored import definitions exist, but the old WXR analysis/execution pipeline and its dedicated editor surface are not present.
- Menu data exists, but the `menu_editor` route still lacks a dedicated editor surface comparable to the old app.
- CLI sync notification persistence exists, but old-app parity for desktop-side watching/broadcasting of external DB mutations is not yet proven.
- The remaining parity write-up gap is now keeping the chat row and the final minimal backlog in sync with the current implementation. - The remaining parity write-up gap is now keeping the chat row and the final minimal backlog in sync with the current implementation.
## Spec Coverage Snapshot ## Spec Coverage Snapshot
@@ -47,11 +44,11 @@ Ordered from base contracts upward:
| Integrations | `git`, `mcp`, `ai`, `embedding`, `cli_sync`, `metadata_diff` | Implemented | Service layer and test coverage exist for these domains. | | Integrations | `git`, `mcp`, `ai`, `embedding`, `cli_sync`, `metadata_diff` | Implemented | Service layer and test coverage exist for these domains. |
| Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Implemented | Route state, registry-backed sidebar/editor coverage, panel fallback rules, menu/native-command wiring, and a LiveView-owned shell frame are in place. | | Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Implemented | Route state, registry-backed sidebar/editor coverage, panel fallback rules, menu/native-command wiring, and a LiveView-owned shell frame are in place. |
| Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. | | Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. |
| Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals` | Partial | The existing editor routes now render dedicated surfaces and have focused tests; import/menu parity and full old-app behavior scoring are still outstanding. | | Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals`, `menu` | Partial | The existing editor routes now render dedicated surfaces and have focused tests; import parity and any later not-yet-implemented old-app behavior remain outstanding. |
## Batch 3 And 4 Focus ## Batch 3 And 4 Focus
Only these two audit tracks matter for the current pass. The follow-on missing-feature tracks now sit explicitly after this pass as steps 10, 11, and 12. Only these two audit tracks matter for the current pass. The follow-on missing-feature tracks now sit explicitly after this pass as steps 11 and 12.
1. Lock compatibility contracts. Completed 2026-04-25. 1. Lock compatibility contracts. Completed 2026-04-25.
Schema, frontmatter, sidecars, template context, generation output, preview behavior, metadata diff, and rebuild behavior are now pinned against the Allium specs and the old bDS application with executable parity tests. Schema, frontmatter, sidecars, template context, generation output, preview behavior, metadata diff, and rebuild behavior are now pinned against the Allium specs and the old bDS application with executable parity tests.
@@ -71,23 +68,23 @@ Only these two audit tracks matter for the current pass. The follow-on missing-f
6. Audit batch 4: lower-risk backend and integration services behind those surfaces. Completed 2026-04-29. 6. Audit batch 4: lower-risk backend and integration services behind those surfaces. Completed 2026-04-29.
Tied each old engine contract to its bDS2 replacement, verified the executable proof set, probed the relevant owning UI/editor entry points, and recorded the batch-4 parity scores in the snapshot below. Tied each old engine contract to its bDS2 replacement, verified the executable proof set, probed the relevant owning UI/editor entry points, and recorded the batch-4 parity scores in the snapshot below.
7. Restore remaining chat editor parity on the implemented route. 7. Restore remaining chat editor parity on the implemented route. Completed 2026-04-29.
Use the old `ChatPanel` and chat surface code as the blueprint, refresh the chat parity row to the current implementation, and close only the remaining proven live-turn/UI drift without widening into unrelated AI features. Re-ran the focused chat parity pass against the old `ChatPanel`/`ChatTranscript` surface, closed the remaining in-flight input-state drift on the implemented route, and refreshed the parity row to green without widening into backend streaming work.
8. Restore misc maintenance editor parity on the implemented routes. Completed 2026-04-28. 8. Restore misc maintenance editor parity on the implemented routes. Completed 2026-04-28.
Translation validation now uses the old dedicated validate-and-fix card surface again, and git diff now renders through the structured Monaco diff viewer while keeping the current metadata-diff, site-validation, and duplicate flows intact. Translation validation now uses the old dedicated validate-and-fix card surface again, and git diff now renders through the structured Monaco diff viewer while keeping the current metadata-diff, site-validation, and duplicate flows intact.
9. Fix any remaining batch 3 and batch 4 parity gaps that the audit proves. 9. Fix any remaining batch 3 and batch 4 parity gaps that the audit proves. Completed 2026-04-29.
Do not widen step 9 into the later menu, CLI-mutation-watching, import, or release-packaging tracks unless one of those blocks a batch 3 or batch 4 parity scenario. No further proven batch-3 or batch-4 gaps remained after the focused chat parity pass, so the minimal backlog now begins with the later menu, CLI-mutation-watching, and import tracks.
10. Restore menu editor parity on the implemented data model. 10. Restore menu editor parity on the implemented data model. Completed 2026-04-29.
Build the dedicated menu editor surface against the existing menu data using the old app as the blueprint for workflow, behavior, UI structure, and styling. The shell now renders a dedicated menu editor with old-app-inspired structure, toolbar flow, inline page/category insertion, drag/drop plus move/indent controls, localized copy, and focused shell-live coverage on the existing menu persistence model.
11. Restore desktop-side CLI mutation watching parity. 11. Restore desktop-side CLI mutation watching parity. Completed 2026-04-29.
Add the old desktop-side watching and invalidation behavior for external database mutations so CLI sync notifications propagate through the shell with the same timing and UX expectations as the old app. A supervised CLI-sync watcher now polls the persisted notification store on the old-app timing budget, broadcasts `entity:changed` events through PubSub, and the LiveView shell refreshes sidebar/editor state while closing stale post/media tabs on external deletes.
12. Restore import execution and editor parity. 12. Restore import execution and editor parity. Completed 2026-04-30.
Extend the existing stored import definitions into the old WXR analysis/execution pipeline and add the dedicated editor surface so import behavior, workflow, and look and feel match the old app. The stored import-definition flow now runs through the old analysis/execution pipeline again with progress callbacks, dedicated import-editor detail sections, inline taxonomy mapping pills plus AI-backed mapping, and focused import proof plus clean compile, dialyzer, and full-suite validation.
## Batch 3 Audit Matrix ## Batch 3 Audit Matrix
@@ -127,7 +124,7 @@ Current batch 3 parity scores for existing implemented UI/editor code only. Miss
| Tags editor | green | `src/renderer/components/TagsView/TagsView.tsx` | `lib/bds/desktop/shell_live/tags_editor.ex`, `lib/bds/desktop/shell_live/tags_editor_html/*` | `mix test test/bds/desktop/shell_live_test.exs`; tags editor implementation compared | No material implemented-code parity drift found in the current create/edit/delete/merge flows, color handling, or post-template selection behavior. | | Tags editor | green | `src/renderer/components/TagsView/TagsView.tsx` | `lib/bds/desktop/shell_live/tags_editor.ex`, `lib/bds/desktop/shell_live/tags_editor_html/*` | `mix test test/bds/desktop/shell_live_test.exs`; tags editor implementation compared | No material implemented-code parity drift found in the current create/edit/delete/merge flows, color handling, or post-template selection behavior. |
| Script editor | green | `src/renderer/components/ScriptsView/ScriptsView.tsx` | `lib/bds/desktop/shell_live/code_entity_editor.ex`, `lib/bds/desktop/shell_live/code_entity_editor_html/script_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; script editor implementation compared | No material implemented-code parity drift found in the current Monaco editor, metadata form, syntax check, run, save, delete, and entrypoint selection flow. | | Script editor | green | `src/renderer/components/ScriptsView/ScriptsView.tsx` | `lib/bds/desktop/shell_live/code_entity_editor.ex`, `lib/bds/desktop/shell_live/code_entity_editor_html/script_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; script editor implementation compared | No material implemented-code parity drift found in the current Monaco editor, metadata form, syntax check, run, save, delete, and entrypoint selection flow. |
| Template editor | green | `src/renderer/components/TemplatesView/TemplatesView.tsx` | `lib/bds/desktop/shell_live/code_entity_editor.ex`, `lib/bds/desktop/shell_live/code_entity_editor_html/template_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; template editor implementation compared | No material implemented-code parity drift found in the current Monaco editor, metadata form, validate, save, and delete flow. | | Template editor | green | `src/renderer/components/TemplatesView/TemplatesView.tsx` | `lib/bds/desktop/shell_live/code_entity_editor.ex`, `lib/bds/desktop/shell_live/code_entity_editor_html/template_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; template editor implementation compared | No material implemented-code parity drift found in the current Monaco editor, metadata form, validate, save, and delete flow. |
| Chat editor | yellow | `src/renderer/components/ChatPanel/ChatPanel.tsx` | `lib/bds/desktop/shell_live/chat_editor.ex`, `lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; chat editor implementation compared | The current bDS2 chat route now covers the old model selector with provider grouping, welcome state, persisted transcript, airplane-mode gating, API-key-required state, assistant markdown rendering, tool markers, structured tool surfaces, assistant navigation actions, and the in-flight stop/abort flow. Step 7 remains open because the chat row still needs one fresh focused pass against the old live-turn surface to pin any remaining proven drift before it can be scored green. | | Chat editor | green | `src/renderer/components/ChatPanel/ChatPanel.tsx`, `src/renderer/components/ChatSurface/ChatTranscript.tsx` | `lib/bds/desktop/shell_live/chat_editor.ex`, `lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex`, `priv/ui/live.js` | `mix test test/bds/desktop/shell_live_test.exs`; chat editor implementation and hook flow compared | The current bDS2 chat route now matches the implemented old-panel surface for provider-grouped model selection, welcome state, persisted transcript, airplane-mode gating, API-key-required state, assistant markdown rendering, tool markers, structured tool surfaces, assistant navigation actions, Enter/Shift+Enter handling, auto-grow/auto-scroll, and in-flight stop/input-lock behavior. No further proven implemented-route drift remains in batch 3. |
| Misc maintenance editors | green | `src/renderer/components/SiteValidationView`, `TranslationValidationView`, `MetadataDiffPanel`, `DuplicatesView`, `GitDiffView` | `lib/bds/desktop/shell_live/misc_editor.ex`, `lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; misc editor implementation compared | No material implemented-code parity drift found in the current metadata diff, site validation, duplicate dismissal, translation validation, or working-tree git diff flows. Translation validation again uses dedicated issue cards with revalidate/fix actions, and git diff now uses the structured Monaco diff surface. | | Misc maintenance editors | green | `src/renderer/components/SiteValidationView`, `TranslationValidationView`, `MetadataDiffPanel`, `DuplicatesView`, `GitDiffView` | `lib/bds/desktop/shell_live/misc_editor.ex`, `lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; misc editor implementation compared | No material implemented-code parity drift found in the current metadata diff, site validation, duplicate dismissal, translation validation, or working-tree git diff flows. Translation validation again uses dedicated issue cards with revalidate/fix actions, and git diff now uses the structured Monaco diff surface. |
## Batch 4 Audit Matrix ## Batch 4 Audit Matrix

View File

@@ -34,6 +34,17 @@ defmodule BDS.AI do
airplane_image_analysis: "ai.airplane.model.image_analysis" airplane_image_analysis: "ai.airplane.model.image_analysis"
} }
@typedoc "Endpoint kind such as :chat, :airplane_chat, :embedding, etc."
@type endpoint_kind :: atom()
@typedoc "Endpoint configuration map."
@type endpoint :: %{kind: endpoint_kind(), url: String.t() | nil, api_key: String.t() | nil, model: String.t() | nil}
@typedoc "Attribute map for endpoint operations."
@type endpoint_attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@spec put_endpoint(endpoint_kind(), endpoint_attrs(), keyword()) ::
{:ok, endpoint()} | {:error, term()}
def put_endpoint(kind, attrs, opts \\ []) when is_atom(kind) and is_map(attrs) and is_list(opts) do def put_endpoint(kind, attrs, opts \\ []) when is_atom(kind) and is_map(attrs) and is_list(opts) do
backend = Keyword.get(opts, :secret_backend, SecretBackend) backend = Keyword.get(opts, :secret_backend, SecretBackend)
kind_key = Atom.to_string(kind) kind_key = Atom.to_string(kind)
@@ -49,6 +60,8 @@ defmodule BDS.AI do
end end
end end
@spec get_endpoint(endpoint_kind(), keyword()) ::
{:ok, endpoint() | nil} | {:error, term()}
def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do
backend = Keyword.get(opts, :secret_backend, SecretBackend) backend = Keyword.get(opts, :secret_backend, SecretBackend)
kind_key = Atom.to_string(kind) kind_key = Atom.to_string(kind)
@@ -67,6 +80,7 @@ defmodule BDS.AI do
end end
end end
@spec delete_endpoint(endpoint_kind()) :: :ok
def delete_endpoint(kind) when is_atom(kind) do def delete_endpoint(kind) when is_atom(kind) do
kind_key = Atom.to_string(kind) kind_key = Atom.to_string(kind)
delete_setting("ai.#{kind_key}.url") delete_setting("ai.#{kind_key}.url")
@@ -75,11 +89,15 @@ defmodule BDS.AI do
:ok :ok
end end
@spec list_endpoint_models(map(), keyword()) :: {:ok, [map()]} | {:error, term()}
def list_endpoint_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do def list_endpoint_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
http_client = Keyword.get(opts, :http_client, Application.get_env(:bds, :ai_http_client, BDS.AI.HttpClient)) http_client = Keyword.get(opts, :http_client, Application.get_env(:bds, :ai_http_client, BDS.AI.HttpClient))
OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client) OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client)
end end
@spec refresh_model_catalog(keyword()) ::
{:ok, %{success: boolean(), models_updated: non_neg_integer(), not_modified: boolean()}}
| {:error, term()}
def refresh_model_catalog(opts \\ []) when is_list(opts) do def refresh_model_catalog(opts \\ []) when is_list(opts) do
http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient) http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient)
@@ -111,6 +129,7 @@ defmodule BDS.AI do
end end
end end
@spec list_catalog_providers() :: [map()]
def list_catalog_providers do def list_catalog_providers do
Repo.all(from provider in CatalogProvider, order_by: [asc: provider.id]) Repo.all(from provider in CatalogProvider, order_by: [asc: provider.id])
|> Enum.map(fn provider -> |> Enum.map(fn provider ->
@@ -126,6 +145,7 @@ defmodule BDS.AI do
end) end)
end end
@spec get_catalog_model(String.t(), String.t() | nil) :: {:ok, map()} | {:error, :not_found}
def get_catalog_model(model_id, provider_id \\ nil) when is_binary(model_id) do def get_catalog_model(model_id, provider_id \\ nil) when is_binary(model_id) do
query = query =
from model in Model, from model in Model,
@@ -144,14 +164,17 @@ defmodule BDS.AI do
end end
end end
@spec catalog_meta(String.t()) :: {:ok, String.t() | nil}
def catalog_meta(key) when is_binary(key) do def catalog_meta(key) when is_binary(key) do
{:ok, get_catalog_meta_value(key)} {:ok, get_catalog_meta_value(key)}
end end
@spec set_airplane_mode(boolean()) :: :ok | {:error, term()}
def set_airplane_mode(enabled) when is_boolean(enabled) do def set_airplane_mode(enabled) when is_boolean(enabled) do
put_setting("ai.airplane_mode_enabled", Atom.to_string(enabled)) put_setting("ai.airplane_mode_enabled", Atom.to_string(enabled))
end end
@spec airplane_mode?(boolean()) :: boolean()
def airplane_mode?(default \\ false) when is_boolean(default) do def airplane_mode?(default \\ false) when is_boolean(default) do
case get_setting("ai.airplane_mode_enabled") do case get_setting("ai.airplane_mode_enabled") do
nil -> default nil -> default
@@ -160,6 +183,7 @@ defmodule BDS.AI do
end end
end end
@spec put_model_preference(atom(), String.t()) :: :ok | {:error, :unknown_model_preference | term()}
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
case Map.fetch(@model_preference_keys, key) do case Map.fetch(@model_preference_keys, key) do
{:ok, setting_key} -> put_setting(setting_key, model) {:ok, setting_key} -> put_setting(setting_key, model)
@@ -167,6 +191,7 @@ defmodule BDS.AI do
end end
end end
@spec get_model_preference(atom()) :: {:ok, String.t() | nil} | {:error, :unknown_model_preference}
def get_model_preference(key) when is_atom(key) do def get_model_preference(key) when is_atom(key) do
case Map.fetch(@model_preference_keys, key) do case Map.fetch(@model_preference_keys, key) do
{:ok, setting_key} -> {:ok, get_setting(setting_key)} {:ok, setting_key} -> {:ok, get_setting(setting_key)}
@@ -174,6 +199,7 @@ defmodule BDS.AI do
end end
end end
@spec put_model_capabilities(String.t(), map()) :: :ok | {:error, term()}
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
capabilities = %{ capabilities = %{
supports_attachment: truthy?(Map.get(attrs, :supports_attachment) || Map.get(attrs, "supports_attachment")), supports_attachment: truthy?(Map.get(attrs, :supports_attachment) || Map.get(attrs, "supports_attachment")),
@@ -183,6 +209,7 @@ defmodule BDS.AI do
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities)) put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
end end
@spec detect_language(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def detect_language(text, opts \\ []) when is_binary(text) and is_list(opts) do def detect_language(text, opts \\ []) when is_binary(text) and is_list(opts) do
run_one_shot( run_one_shot(
:detect_language, :detect_language,
@@ -194,6 +221,7 @@ defmodule BDS.AI do
) )
end end
@spec analyze_taxonomy(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def analyze_taxonomy(post_input, opts \\ []) when is_list(opts) do def analyze_taxonomy(post_input, opts \\ []) when is_list(opts) do
with {:ok, post} <- normalize_post_input(post_input) do with {:ok, post} <- normalize_post_input(post_input) do
run_one_shot( run_one_shot(
@@ -212,6 +240,42 @@ defmodule BDS.AI do
end end
end end
@spec analyze_import_taxonomy(map(), map(), keyword()) :: {:ok, map()} | {:error, term()}
def analyze_import_taxonomy(import_terms, existing_terms, opts \\ [])
when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do
payload = %{
import_categories: normalize_string_list(Map.get(import_terms, :categories) || Map.get(import_terms, "categories")),
import_tags: normalize_string_list(Map.get(import_terms, :tags) || Map.get(import_terms, "tags")),
existing_categories: normalize_string_list(Map.get(existing_terms, :categories) || Map.get(existing_terms, "categories")),
existing_tags: normalize_string_list(Map.get(existing_terms, :tags) || Map.get(existing_terms, "tags"))
}
run_one_shot(
:import_taxonomy_mapping,
payload,
opts,
fn json, usage ->
{:ok,
%{
category_mappings:
filter_taxonomy_mapping_response(
json["categoryMappings"] || json["category_mappings"],
payload.import_categories,
payload.existing_categories
),
tag_mappings:
filter_taxonomy_mapping_response(
json["tagMappings"] || json["tag_mappings"],
payload.import_tags,
payload.existing_tags
),
usage: usage
}}
end
)
end
@spec analyze_post(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def analyze_post(post_input, opts \\ []) when is_list(opts) do def analyze_post(post_input, opts \\ []) when is_list(opts) do
with {:ok, post} <- normalize_post_input(post_input) do with {:ok, post} <- normalize_post_input(post_input) do
run_one_shot( run_one_shot(
@@ -231,6 +295,7 @@ defmodule BDS.AI do
end end
end end
@spec translate_post(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def translate_post(post_input, target_language, opts \\ []) def translate_post(post_input, target_language, opts \\ [])
when is_binary(target_language) and is_list(opts) do when is_binary(target_language) and is_list(opts) do
with {:ok, post} <- normalize_post_input(post_input) do with {:ok, post} <- normalize_post_input(post_input) do
@@ -251,6 +316,7 @@ defmodule BDS.AI do
end end
end end
@spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def analyze_image(media_input, opts \\ []) when is_list(opts) do def analyze_image(media_input, opts \\ []) when is_list(opts) do
with {:ok, media} <- normalize_media_input(media_input), with {:ok, media} <- normalize_media_input(media_input),
:ok <- ensure_image_media(media) do :ok <- ensure_image_media(media) do
@@ -271,6 +337,7 @@ defmodule BDS.AI do
end end
end end
@spec translate_media(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def translate_media(media_input, target_language, opts \\ []) def translate_media(media_input, target_language, opts \\ [])
when is_binary(target_language) and is_list(opts) do when is_binary(target_language) and is_list(opts) do
with {:ok, media} <- normalize_media_input(media_input) do with {:ok, media} <- normalize_media_input(media_input) do
@@ -291,6 +358,7 @@ defmodule BDS.AI do
end end
end end
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
def start_chat(attrs \\ %{}) when is_map(attrs) do def start_chat(attrs \\ %{}) when is_map(attrs) do
now = Persistence.now_ms() now = Persistence.now_ms()
model = Map.get(attrs, :model) || Map.get(attrs, "model") model = Map.get(attrs, :model) || Map.get(attrs, "model")
@@ -312,11 +380,13 @@ defmodule BDS.AI do
end end
end end
@spec list_chat_conversations() :: [map()]
def list_chat_conversations do def list_chat_conversations do
Repo.all(from conversation in ChatConversation, order_by: [desc: conversation.updated_at]) Repo.all(from conversation in ChatConversation, order_by: [desc: conversation.updated_at])
|> Enum.map(&format_conversation/1) |> Enum.map(&format_conversation/1)
end end
@spec available_chat_models(String.t() | nil) :: [map()]
def available_chat_models(current_model \\ nil) do def available_chat_models(current_model \\ nil) do
endpoint_models = configured_chat_models() endpoint_models = configured_chat_models()
@@ -344,6 +414,8 @@ defmodule BDS.AI do
end) end)
end end
@spec set_conversation_model(String.t(), String.t()) ::
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
def set_conversation_model(conversation_id, model_id) def set_conversation_model(conversation_id, model_id)
when is_binary(conversation_id) and is_binary(model_id) do when is_binary(conversation_id) and is_binary(model_id) do
case Repo.get(ChatConversation, conversation_id) do case Repo.get(ChatConversation, conversation_id) do
@@ -361,6 +433,7 @@ defmodule BDS.AI do
end end
end end
@spec list_chat_messages(String.t()) :: [map()]
def list_chat_messages(conversation_id) when is_binary(conversation_id) do def list_chat_messages(conversation_id) when is_binary(conversation_id) do
Repo.all( Repo.all(
from message in ChatMessage, from message in ChatMessage,
@@ -370,6 +443,8 @@ defmodule BDS.AI do
|> Enum.map(&format_chat_message/1) |> Enum.map(&format_chat_message/1)
end end
@spec send_chat_message(String.t(), String.t(), keyword()) ::
{:ok, map()} | {:error, :not_found | term()}
def send_chat_message(conversation_id, content, opts \\ []) def send_chat_message(conversation_id, content, opts \\ [])
when is_binary(conversation_id) and is_binary(content) and is_list(opts) do when is_binary(conversation_id) and is_binary(content) and is_list(opts) do
with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id), with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id),
@@ -403,6 +478,7 @@ defmodule BDS.AI do
end end
end end
@spec cancel_chat(String.t()) :: :ok
def cancel_chat(conversation_id) when is_binary(conversation_id) do def cancel_chat(conversation_id) when is_binary(conversation_id) do
case InFlight.lookup(conversation_id) do case InFlight.lookup(conversation_id) do
nil -> :ok nil -> :ok
@@ -559,7 +635,7 @@ defmodule BDS.AI do
defp run_one_shot(operation, payload, opts, formatter) do defp run_one_shot(operation, payload, opts, formatter) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime) runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- resolve_runtime_target(operation, secret_backend: Keyword.get(opts, :secret_backend, SecretBackend)), with {:ok, endpoint, model, mode} <- resolve_runtime_target(operation, opts),
:ok <- validate_runtime_target(operation, model, mode), :ok <- validate_runtime_target(operation, model, mode),
request <- build_one_shot_request(operation, payload, model), request <- build_one_shot_request(operation, payload, model),
{:ok, response} <- runtime.generate(endpoint_with_model(endpoint, model), request, opts), {:ok, response} <- runtime.generate(endpoint_with_model(endpoint, model), request, opts),
@@ -903,20 +979,20 @@ defmodule BDS.AI do
{:ok, get_model_preference_value(:chat) || endpoint.model} {:ok, get_model_preference_value(:chat) || endpoint.model}
end end
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, _extra) do defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do
{:ok, get_model_preference_value(:airplane_image_analysis) || endpoint.model} {:ok, Keyword.get(extra, :model) || get_model_preference_value(:airplane_image_analysis) || endpoint.model}
end end
defp resolve_model_for_operation(:analyze_image, :online, endpoint, _extra) do defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do
{:ok, get_model_preference_value(:image_analysis) || endpoint.model} {:ok, Keyword.get(extra, :model) || get_model_preference_value(:image_analysis) || endpoint.model}
end end
defp resolve_model_for_operation(_operation, :airplane, endpoint, _extra) do defp resolve_model_for_operation(_operation, :airplane, endpoint, extra) do
{:ok, get_model_preference_value(:airplane_title) || endpoint.model} {:ok, Keyword.get(extra, :model) || get_model_preference_value(:airplane_title) || endpoint.model}
end end
defp resolve_model_for_operation(_operation, :online, endpoint, _extra) do defp resolve_model_for_operation(_operation, :online, endpoint, extra) do
{:ok, get_model_preference_value(:title) || endpoint.model} {:ok, Keyword.get(extra, :model) || get_model_preference_value(:title) || endpoint.model}
end end
defp validate_runtime_target(:analyze_image, model, _mode) do defp validate_runtime_target(:analyze_image, model, _mode) do
@@ -990,6 +1066,49 @@ defmodule BDS.AI do
|> MapSet.size() |> MapSet.size()
end end
defp normalize_string_list(values) do
values
|> List.wrap()
|> Enum.map(&to_string/1)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
end
defp filter_taxonomy_mapping_response(mappings, import_terms, existing_terms) when is_map(mappings) do
import_lookup = canonical_term_lookup(import_terms)
existing_lookup = canonical_term_lookup(existing_terms)
Enum.reduce(mappings, %{}, fn {source, target}, acc ->
with {:ok, canonical_source} <- resolve_canonical_term(source, import_lookup),
{:ok, canonical_target} <- resolve_canonical_term(target, existing_lookup) do
Map.put(acc, canonical_source, canonical_target)
else
_other -> acc
end
end)
end
defp filter_taxonomy_mapping_response(_mappings, _import_terms, _existing_terms), do: %{}
defp canonical_term_lookup(terms) do
Map.new(terms, fn term -> {normalize_term(term), term} end)
end
defp resolve_canonical_term(term, lookup) do
case Map.get(lookup, normalize_term(term)) do
nil -> :error
canonical -> {:ok, canonical}
end
end
defp normalize_term(term) do
term
|> to_string()
|> String.trim()
|> String.downcase()
end
defp one_shot_system_prompt(:detect_language) do defp one_shot_system_prompt(:detect_language) do
"Return JSON with exactly one key: language_code." "Return JSON with exactly one key: language_code."
end end
@@ -998,6 +1117,10 @@ defmodule BDS.AI do
"Return JSON with keys tags and categories, each an array of short strings." "Return JSON with keys tags and categories, each an array of short strings."
end end
defp one_shot_system_prompt(:import_taxonomy_mapping) do
"You are helping import WordPress taxonomy into an existing blog. Return JSON with exactly two keys: categoryMappings and tagMappings. Each value must be an object mapping imported term names to existing project term names. Only map when the imported term should reuse an existing term to avoid duplicates. Do not invent target terms. Leave unmapped items out of the objects."
end
defp one_shot_system_prompt(:analyze_post) do defp one_shot_system_prompt(:analyze_post) do
"Return JSON with keys title, excerpt, and slug." "Return JSON with keys title, excerpt, and slug."
end end
@@ -1022,6 +1145,27 @@ defmodule BDS.AI do
"Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}" "Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end end
defp one_shot_user_content(:import_taxonomy_mapping, payload) do
[
"Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.",
"",
"Imported categories:",
Enum.join(payload.import_categories, ", "),
"",
"Imported tags:",
Enum.join(payload.import_tags, ", "),
"",
"Existing project categories:",
Enum.join(payload.existing_categories, ", "),
"",
"Existing project tags:",
Enum.join(payload.existing_tags, ", "),
"",
"Return JSON only."
]
|> Enum.join("\n")
end
defp one_shot_user_content(:analyze_post, post) do defp one_shot_user_content(:analyze_post, post) do
"Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}" "Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end end

View File

@@ -17,7 +17,7 @@ defmodule BDS.Application do
def desktop_children(_env) do def desktop_children(_env) do
if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do
[{BDS.Desktop.Server, []} | desktop_window_children()] [{BDS.Desktop.Server, []}, BDS.CliSync.Watcher | desktop_window_children()]
else else
[] []
end end

View File

@@ -0,0 +1,73 @@
defmodule BDS.CliSync.Watcher do
@moduledoc false
use GenServer
alias BDS.CliSync
@topic "entity:changed"
@default_poll_interval_ms 100
def start_link(opts \\ []) do
case Keyword.pop(opts, :name, __MODULE__) do
{nil, init_opts} -> GenServer.start_link(__MODULE__, init_opts)
{name, init_opts} -> GenServer.start_link(__MODULE__, init_opts, name: name)
end
end
def topic, do: @topic
def poll_now(server \\ __MODULE__) do
GenServer.call(server, :poll_now)
end
@impl true
def init(opts) do
state = %{
poll_interval_ms: normalize_positive_integer(Keyword.get(opts, :poll_interval_ms), @default_poll_interval_ms),
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub)
}
{:ok, schedule_poll(state)}
end
@impl true
def handle_call(:poll_now, _from, state) do
{:reply, :ok, process_notifications(state)}
end
@impl true
def handle_info(:poll, state) do
{:noreply,
state
|> process_notifications()
|> schedule_poll()}
end
defp process_notifications(state) do
{:ok, notifications} = CliSync.db_file_change_detected()
{:ok, _pruned} = CliSync.prune_notifications()
Enum.each(notifications, fn notification ->
Phoenix.PubSub.broadcast(state.pubsub, topic(), {:entity_changed, notification_payload(notification)})
end)
state
end
defp notification_payload(notification) do
%{
entity: notification.entity_type,
entity_id: notification.entity_id,
action: notification.action
}
end
defp schedule_poll(state) do
Process.send_after(self(), :poll, state.poll_interval_ms)
state
end
defp normalize_positive_integer(value, _default) when is_integer(value) and value > 0, do: value
defp normalize_positive_integer(_value, default), do: default
end

View File

@@ -243,11 +243,15 @@ defmodule BDS.Desktop.Automation do
{messages, buffer} = split_driver_buffer(state.driver_buffer) {messages, buffer} = split_driver_buffer(state.driver_buffer)
case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, {acc, _} -> case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, {acc, _} ->
decoded = Jason.decode!(message) case decode_driver_message(message) do
:skip ->
{:cont, {acc, nil}}
case matcher.(decoded) do {:ok, decoded} ->
{:ok, reply} -> {:halt, {acc, reply}} case matcher.(decoded) do
:continue -> {:cont, {acc, nil}} {:ok, reply} -> {:halt, {acc, reply}}
:continue -> {:cont, {acc, nil}}
end
end end
end) do end) do
{state, nil} -> {state, nil} ->
@@ -282,6 +286,24 @@ defmodule BDS.Desktop.Automation do
end end
end end
defp decode_driver_message(message) do
trimmed = String.trim(message)
cond do
trimmed == "" ->
:skip
not String.starts_with?(trimmed, "{") ->
:skip
true ->
case Jason.decode(trimmed) do
{:ok, decoded} -> {:ok, decoded}
{:error, _reason} -> :skip
end
end
end
defp wait_for_server(base_url) do defp wait_for_server(base_url) do
deadline = System.monotonic_time(:millisecond) + @ready_timeout deadline = System.monotonic_time(:millisecond) + @ready_timeout
do_wait_for_server(base_url, deadline) do_wait_for_server(base_url, deadline)

View File

@@ -6,8 +6,9 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML import Phoenix.HTML
alias BDS.AI alias BDS.AI
alias BDS.CliSync.Watcher
alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData} alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData}
alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MiscEditor, SettingsEditor, TagsEditor} alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, ImportEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor}
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
alias BDS.Desktop.ShellLive.PostEditor alias BDS.Desktop.ShellLive.PostEditor
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
@@ -47,6 +48,7 @@ defmodule BDS.Desktop.ShellLive do
connected = connected?(socket) connected = connected?(socket)
if connected do if connected do
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
:timer.send_interval(@refresh_interval, :refresh_task_status) :timer.send_interval(@refresh_interval, :refresh_task_status)
end end
@@ -103,6 +105,14 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:chat_editor_surface_data, %{}) |> assign(:chat_editor_surface_data, %{})
|> assign(:chat_editor_surface_tabs, %{}) |> assign(:chat_editor_surface_tabs, %{})
|> assign(:chat_editor_action_errors, %{}) |> assign(:chat_editor_action_errors, %{})
|> assign(:import_editor_analysis_states, %{})
|> assign(:import_editor_analysis_task_refs, %{})
|> assign(:import_editor_execution_states, %{})
|> assign(:import_editor_execution_task_refs, %{})
|> assign(:import_editor_sections, %{})
|> assign(:import_editor_taxonomy_edits, %{})
|> assign(:import_editor_model_selectors_open, %{})
|> assign(:import_editor_selected_models, %{})
|> assign(:misc_editor_selected_pairs, %{}) |> assign(:misc_editor_selected_pairs, %{})
|> assign(:misc_editor_git_selected_files, %{}) |> assign(:misc_editor_git_selected_files, %{})
|> assign(:metadata_diff_active_tabs, %{}) |> assign(:metadata_diff_active_tabs, %{})
@@ -614,6 +624,46 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, SettingsEditor.apply_style_theme(socket, &reload_shell/2, &append_output_entry/5)} {:noreply, SettingsEditor.apply_style_theme(socket, &reload_shell/2, &append_output_entry/5)}
end end
def handle_event("menu_editor_select_item", %{"item_id" => item_id}, socket) do
{:noreply, MenuEditor.select_item(socket, item_id, &reload_shell/2)}
end
def handle_event("change_menu_editor_entry", %{"menu_editor_entry" => params}, socket) do
{:noreply, MenuEditor.change_entry(socket, params, &reload_shell/2)}
end
def handle_event("submit_menu_editor_entry", _params, socket) do
{:noreply, MenuEditor.submit_entry(socket, &reload_shell/2)}
end
def handle_event("cancel_menu_editor_entry", _params, socket) do
{:noreply, MenuEditor.cancel_entry(socket, &reload_shell/2)}
end
def handle_event("select_menu_editor_page", %{"post_id" => post_id}, socket) do
{:noreply, MenuEditor.select_page(socket, post_id, &reload_shell/2)}
end
def handle_event("select_menu_editor_category", %{"name" => name}, socket) do
{:noreply, MenuEditor.select_category(socket, name, &reload_shell/2)}
end
def handle_event("menu_editor_toolbar_action", %{"action" => action}, socket) do
{:noreply, MenuEditor.toolbar_action(socket, action, &reload_shell/2, &append_output_entry/5)}
end
def handle_event(
"menu_editor_drop_item",
%{"drag_item_id" => drag_item_id, "target_item_id" => target_item_id, "position" => position},
socket
) do
{:noreply, MenuEditor.drop_item(socket, drag_item_id, target_item_id, position, &reload_shell/2)}
end
def handle_event("menu_editor_keydown", %{"key" => key}, socket) do
{:noreply, MenuEditor.handle_keydown(socket, key, &reload_shell/2)}
end
def handle_event("toggle_tag_selection", %{"name" => tag_name}, socket) do def handle_event("toggle_tag_selection", %{"name" => tag_name}, socket) do
{:noreply, TagsEditor.toggle_selection(socket, tag_name, &reload_shell/2)} {:noreply, TagsEditor.toggle_selection(socket, tag_name, &reload_shell/2)}
end end
@@ -725,6 +775,58 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, handle_chat_surface_action(socket, params)} {:noreply, handle_chat_surface_action(socket, params)}
end end
def handle_event("change_import_editor_definition", %{"import_definition" => params}, socket) do
{:noreply, ImportEditor.change_definition(socket, params, &reload_shell/2)}
end
def handle_event("select_import_uploads_folder", _params, socket) do
{:noreply, ImportEditor.select_uploads_folder(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("select_import_wxr_file", _params, socket) do
{:noreply, ImportEditor.select_and_analyze(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("execute_import_editor", _params, socket) do
{:noreply, ImportEditor.execute_import(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("change_import_conflict_resolution", params, socket) do
{:noreply, ImportEditor.change_conflict_resolution(socket, params, &reload_shell/2)}
end
def handle_event("start_import_taxonomy_edit", params, socket) do
{:noreply, ImportEditor.start_taxonomy_edit(socket, params, &reload_shell/2)}
end
def handle_event("save_import_taxonomy_edit", params, socket) do
{:noreply, ImportEditor.save_taxonomy_edit(socket, params, &reload_shell/2)}
end
def handle_event("cancel_import_taxonomy_edit", _params, socket) do
{:noreply, ImportEditor.cancel_taxonomy_edit(socket, &reload_shell/2)}
end
def handle_event("clear_import_taxonomy_mapping", params, socket) do
{:noreply, ImportEditor.clear_taxonomy_mapping(socket, params, &reload_shell/2)}
end
def handle_event("toggle_import_section", %{"section" => section}, socket) do
{:noreply, ImportEditor.toggle_section(socket, section, &reload_shell/2)}
end
def handle_event("toggle_import_ai_model_selector", _params, socket) do
{:noreply, ImportEditor.toggle_model_selector(socket, &reload_shell/2)}
end
def handle_event("select_import_ai_model", %{"model" => model_id}, socket) do
{:noreply, ImportEditor.select_ai_model(socket, model_id, &reload_shell/2)}
end
def handle_event("analyze_import_taxonomy_ai", _params, socket) do
{:noreply, ImportEditor.analyze_taxonomy_ai(socket, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("rerun_misc_editor", _params, socket) do def handle_event("rerun_misc_editor", _params, socket) do
case MiscEditor.rerun(socket) do case MiscEditor.rerun(socket) do
{:command, action} -> {:noreply, apply_shell_command(socket, action)} {:command, action} -> {:noreply, apply_shell_command(socket, action)}
@@ -1098,19 +1200,46 @@ defmodule BDS.Desktop.ShellLive do
@impl true @impl true
def handle_info({ref, result}, socket) when is_reference(ref) do def handle_info({ref, result}, socket) when is_reference(ref) do
Process.demonitor(ref, [:flush]) Process.demonitor(ref, [:flush])
{:noreply, ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
cond do
Map.has_key?(socket.assigns.import_editor_analysis_task_refs, ref) ->
{:noreply, ImportEditor.finish_analysis(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
Map.has_key?(socket.assigns.import_editor_execution_task_refs, ref) ->
{:noreply, ImportEditor.finish_execution(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
true ->
{:noreply, ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
end
end end
def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do
next_socket = next_socket =
case reason do cond do
:normal -> socket Map.has_key?(socket.assigns.import_editor_analysis_task_refs, ref) ->
_other -> ChatEditor.finish_request(socket, ref, {:error, :cancelled}, &reload_shell/2, &append_output_entry/5) ImportEditor.handle_task_down(socket, :analysis, ref, reason, &reload_shell/2, &append_output_entry/5)
Map.has_key?(socket.assigns.import_editor_execution_task_refs, ref) ->
ImportEditor.handle_task_down(socket, :execution, ref, reason, &reload_shell/2, &append_output_entry/5)
true ->
case reason do
:normal -> socket
_other -> ChatEditor.finish_request(socket, ref, {:error, :cancelled}, &reload_shell/2, &append_output_entry/5)
end
end end
{:noreply, next_socket} {:noreply, next_socket}
end end
def handle_info({:import_analysis_progress, definition_id, step, detail}, socket) do
{:noreply, ImportEditor.note_analysis_progress(socket, definition_id, step, detail, &reload_shell/2)}
end
def handle_info({:import_execution_progress, definition_id, phase, current, total, detail}, socket) do
{:noreply, ImportEditor.note_execution_progress(socket, definition_id, phase, current, total, detail, &reload_shell/2)}
end
def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do
{:noreply, ChatEditor.note_tool_call(socket, conversation_id, tool_call, &reload_shell/2)} {:noreply, ChatEditor.note_tool_call(socket, conversation_id, tool_call, &reload_shell/2)}
end end
@@ -1123,6 +1252,10 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)} {:noreply, ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)}
end end
def handle_info({:entity_changed, payload}, socket) when is_map(payload) do
{:noreply, apply_cli_entity_change(socket, payload)}
end
def handle_info(:refresh_task_status, socket) do def handle_info(:refresh_task_status, socket) do
raw_task_status = BDS.Tasks.status_snapshot() raw_task_status = BDS.Tasks.status_snapshot()
@@ -1205,12 +1338,125 @@ defmodule BDS.Desktop.ShellLive do
|> assign_post_editor() |> assign_post_editor()
|> assign_media_editor() |> assign_media_editor()
|> assign_settings_editor() |> assign_settings_editor()
|> assign_menu_editor()
|> assign_tags_editor() |> assign_tags_editor()
|> assign_code_entity_editor() |> assign_code_entity_editor()
|> assign_chat_editor() |> assign_chat_editor()
|> assign_import_editor()
|> assign_misc_editor() |> assign_misc_editor()
end end
defp apply_cli_entity_change(socket, payload) do
entity = Map.get(payload, :entity) || Map.get(payload, "entity") || Map.get(payload, :entity_type) || Map.get(payload, "entity_type")
entity_id = Map.get(payload, :entity_id) || Map.get(payload, "entity_id") || Map.get(payload, :entityId) || Map.get(payload, "entityId")
action = normalize_cli_entity_action(Map.get(payload, :action) || Map.get(payload, "action"))
if is_binary(entity) and entity != "" and is_binary(entity_id) and entity_id != "" and action in [:created, :updated, :deleted] do
{socket, workbench} = maybe_close_deleted_cli_tab(socket, entity, entity_id, action)
socket
|> maybe_refresh_cli_tab_meta(entity, entity_id, action)
|> reload_shell(workbench)
else
socket
end
end
defp maybe_close_deleted_cli_tab(socket, "post", post_id, :deleted) do
workbench = Workbench.close_tab(socket.assigns.workbench, :post, post_id)
socket =
socket
|> assign(:workbench, workbench)
|> assign(:shell_overlay, nil)
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id}))
|> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id))
|> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id))
|> assign(:post_editor_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id))
|> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id))
|> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, post_id))
|> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id))
|> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id))
|> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id))
{socket, workbench}
end
defp maybe_close_deleted_cli_tab(socket, "media", media_id, :deleted) do
workbench = Workbench.close_tab(socket.assigns.workbench, :media, media_id)
socket =
socket
|> assign(:workbench, workbench)
|> assign(:shell_overlay, nil)
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id))
|> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id))
|> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id))
|> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id))
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
{socket, workbench}
end
defp maybe_close_deleted_cli_tab(socket, _entity, _entity_id, _action), do: {socket, socket.assigns.workbench}
defp maybe_refresh_cli_tab_meta(socket, "post", post_id, action) when action in [:created, :updated] do
maybe_put_cli_tab_meta(socket, :post, post_id, fn ->
case Repo.get(Post, post_id) do
%Post{} = post -> %{title: post.title || post.slug || post.id, subtitle: Atom.to_string(post.status || :draft)}
_other -> nil
end
end)
end
defp maybe_refresh_cli_tab_meta(socket, "media", media_id, action) when action in [:created, :updated] do
maybe_put_cli_tab_meta(socket, :media, media_id, fn ->
case Repo.get(Media, media_id) do
%Media{} = media -> %{title: media.title || media.filename || media.id, subtitle: media.filename || media.mime_type || "media"}
_other -> nil
end
end)
end
defp maybe_refresh_cli_tab_meta(socket, _entity, _entity_id, _action), do: socket
defp maybe_put_cli_tab_meta(socket, route, entity_id, meta_fun) do
key = {route, entity_id}
if cli_tab_present?(socket.assigns.workbench, key) or Map.has_key?(socket.assigns.tab_meta, key) do
case meta_fun.() do
%{} = fresh_meta ->
updated_meta = Map.update(socket.assigns.tab_meta, key, fresh_meta, &Map.merge(&1, fresh_meta))
assign(socket, :tab_meta, updated_meta)
_other ->
socket
end
else
socket
end
end
defp cli_tab_present?(%{tabs: tabs}, {route, entity_id}) do
Enum.any?(tabs, &(&1.type == route and &1.id == entity_id))
end
defp normalize_cli_entity_action(action) when action in [:created, :updated, :deleted], do: action
defp normalize_cli_entity_action(action) do
action
|> to_string()
|> String.downcase()
|> case do
"created" -> :created
"updated" -> :updated
"deleted" -> :deleted
_other -> :unknown
end
end
defp render_panel_body(assigns) do defp render_panel_body(assigns) do
case assigns.workbench.panel.active_tab do case assigns.workbench.panel.active_tab do
:tasks -> render_task_entries(assigns) :tasks -> render_task_entries(assigns)
@@ -1444,6 +1690,10 @@ defmodule BDS.Desktop.ShellLive do
SettingsEditor.assign_socket(socket) SettingsEditor.assign_socket(socket)
end end
defp assign_menu_editor(socket) do
MenuEditor.assign_socket(socket)
end
defp assign_tags_editor(socket) do defp assign_tags_editor(socket) do
TagsEditor.assign_socket(socket) TagsEditor.assign_socket(socket)
end end
@@ -1456,6 +1706,10 @@ defmodule BDS.Desktop.ShellLive do
ChatEditor.assign_socket(socket) ChatEditor.assign_socket(socket)
end end
defp assign_import_editor(socket) do
ImportEditor.assign_socket(socket)
end
defp assign_misc_editor(socket) do defp assign_misc_editor(socket) do
MiscEditor.assign_socket(socket) MiscEditor.assign_socket(socket)
end end

View File

@@ -915,7 +915,6 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp blank?(value) when is_binary(value), do: String.trim(value) == "" defp blank?(value) when is_binary(value), do: String.trim(value) == ""
defp blank?(nil), do: true defp blank?(nil), do: true
defp blank?(_value), do: false
defp rewrite_external_images(html) do defp rewrite_external_images(html) do
html = html =

View File

@@ -183,7 +183,7 @@
<% end %> <% end %>
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message"> <form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message">
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("chat.inputPlaceholder")}><%= @chat_editor.input %></textarea> <textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("chat.inputPlaceholder")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
<button class="chat-send-button" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={@chat_editor.send_disabled?}>↑</button> <button class="chat-send-button" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={@chat_editor.send_disabled?}>↑</button>
</form> </form>

File diff suppressed because it is too large Load Diff

View File

@@ -394,6 +394,9 @@
<% @current_tab.type == :style and @style_editor -> %> <% @current_tab.type == :style and @style_editor -> %>
<SettingsEditor.style_editor style_editor={@style_editor} /> <SettingsEditor.style_editor style_editor={@style_editor} />
<% @current_tab.type == :menu_editor and @menu_editor -> %>
<MenuEditor.menu_editor menu_editor={@menu_editor} />
<% @current_tab.type == :tags and @tags_editor -> %> <% @current_tab.type == :tags and @tags_editor -> %>
<TagsEditor.tags_editor tags_editor={@tags_editor} /> <TagsEditor.tags_editor tags_editor={@tags_editor} />
@@ -406,6 +409,9 @@
<% @current_tab.type == :chat and @chat_editor -> %> <% @current_tab.type == :chat and @chat_editor -> %>
<ChatEditor.chat_editor chat_editor={@chat_editor} /> <ChatEditor.chat_editor chat_editor={@chat_editor} />
<% @current_tab.type == :import and @import_editor -> %>
<ImportEditor.import_editor import_editor={@import_editor} />
<% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] and @misc_editor -> %> <% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] and @misc_editor -> %>
<MiscEditor.misc_editor misc_editor={@misc_editor} /> <MiscEditor.misc_editor misc_editor={@misc_editor} />

View File

@@ -0,0 +1,871 @@
defmodule BDS.Desktop.ShellLive.MenuEditor do
@moduledoc false
use Phoenix.Component
import Ecto.Query
alias BDS.Desktop.ShellData
alias BDS.{Menu, Metadata, Repo}
alias BDS.Posts.Post
embed_templates "menu_editor_html/*"
@home_item_id "menu-home"
def assign_socket(socket) do
case socket.assigns[:current_tab] do
%{type: :menu_editor, id: tab_id} ->
state = ensure_state(socket.assigns)
menu_editor = build(socket.assigns, state)
socket
|> assign(:menu_editor_state, state)
|> assign(:menu_editor, menu_editor)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:menu_editor, tab_id}, %{
title: translated("menuEditor.tabTitle"),
subtitle: translated("menuEditor.description")
})
)
_other ->
assign(socket, :menu_editor, nil)
end
end
def select_item(socket, item_id, reload) do
socket
|> update_state(fn state -> %{state | selected_id: item_id} end)
|> reload.(socket.assigns.workbench)
end
def change_entry(socket, params, reload) do
query = Map.get(params, "query", "")
socket
|> update_state(fn state -> put_in(state, [:draft, :query], query) end)
|> reload.(socket.assigns.workbench)
end
def submit_entry(socket, reload) do
case current_draft(socket.assigns) do
%{type: :page} ->
socket
|> update_state(&finalize_submenu_draft/1)
|> reload.(socket.assigns.workbench)
%{type: :category} ->
socket
|> confirm_category_draft()
|> reload.(socket.assigns.workbench)
_other ->
reload.(socket, socket.assigns.workbench)
end
end
def cancel_entry(socket, reload) do
socket
|> update_state(&cancel_draft/1)
|> reload.(socket.assigns.workbench)
end
def select_page(socket, post_id, reload) do
case page_post(socket.assigns.projects.active_project_id, post_id) do
nil -> reload.(socket, socket.assigns.workbench)
post ->
socket
|> update_state(&assign_page_to_draft(&1, post))
|> reload.(socket.assigns.workbench)
end
end
def select_category(socket, name, reload) do
project_id = socket.assigns.projects.active_project_id
case Enum.find(category_options(project_id), &(&1.name == name)) do
nil -> reload.(socket, socket.assigns.workbench)
category ->
socket
|> update_state(&assign_category_to_draft(&1, category))
|> reload.(socket.assigns.workbench)
end
end
def toolbar_action(socket, action, reload, append_output) do
case action do
"add-entry" ->
socket
|> update_state(&start_page_draft/1)
|> reload.(socket.assigns.workbench)
"add-category-archive" ->
socket
|> update_state(&start_category_draft/1)
|> reload.(socket.assigns.workbench)
"save" ->
save(socket, reload, append_output)
"move-up" ->
socket
|> update_state(&move_selected(&1, :up))
|> reload.(socket.assigns.workbench)
"move-down" ->
socket
|> update_state(&move_selected(&1, :down))
|> reload.(socket.assigns.workbench)
"indent" ->
socket
|> update_state(&indent_selected/1)
|> reload.(socket.assigns.workbench)
"unindent" ->
socket
|> update_state(&unindent_selected/1)
|> reload.(socket.assigns.workbench)
"delete" ->
socket
|> update_state(&delete_selected/1)
|> reload.(socket.assigns.workbench)
_other ->
reload.(socket, socket.assigns.workbench)
end
end
def drop_item(socket, drag_item_id, target_item_id, position, reload) do
socket
|> update_state(&drop_selected(&1, drag_item_id, target_item_id, position))
|> reload.(socket.assigns.workbench)
end
def handle_keydown(socket, "Escape", reload) do
cancel_entry(socket, reload)
end
def handle_keydown(socket, _key, reload) do
reload.(socket, socket.assigns.workbench)
end
attr :menu_editor, :map, required: true
def menu_editor(assigns)
attr :items, :list, required: true
attr :menu_editor, :map, required: true
attr :depth, :integer, required: true
def menu_tree_level(assigns) do
~H"""
<%= for item <- @items do %>
<li class="menu-editor-tree-item">
<% editing? = draft_item?(@menu_editor, item.item_id) %>
<% selected? = item.item_id == @menu_editor.selected_id %>
<div
class={[
"menu-editor-row",
if(selected?, do: "is-selected"),
if(editing?, do: "is-editing")
]}
data-testid="menu-editor-row"
data-menu-item-id={item.item_id}
data-menu-kind={item.kind}
data-menu-label={row_label(item, @menu_editor.category_titles)}
data-menu-can-drop-inside={to_string(item.kind == :submenu)}
data-selected={to_string(selected?)}
phx-click={unless(editing?, do: "menu_editor_select_item")}
phx-value-item_id={unless(editing?, do: item.item_id)}
style={"--menu-editor-depth: #{@depth};"}
>
<span class="menu-editor-row-handle" data-menu-drag-handle="true" title={translated("menuEditor.dragHandle")}>⋮⋮</span>
<span class="menu-editor-row-kind" title={kind_label(item.kind)} aria-label={kind_label(item.kind)}>
<.kind_icon kind={item.kind} />
</span>
<%= if editing? do %>
<div class="menu-editor-row-title is-editing">
<form
class="menu-editor-entry-form"
data-testid="menu-editor-entry-form"
phx-change="change_menu_editor_entry"
phx-submit="submit_menu_editor_entry"
>
<input
class="menu-editor-inline-input"
type="text"
name="menu_editor_entry[query]"
value={@menu_editor.draft_query}
placeholder={editing_placeholder(@menu_editor)}
autocomplete="off"
/>
<div class="menu-editor-inline-search">
<div class="menu-editor-inline-search-head">
<div>
<strong><%= editing_title(@menu_editor) %></strong>
<span><%= editing_hint(@menu_editor) %></span>
</div>
<div class="menu-editor-inline-actions">
<%= if @menu_editor.draft.type == :page do %>
<button class="menu-editor-inline-action" data-testid="menu-editor-create-submenu" type="submit">
<%= translated("menuEditor.addSubmenu") %>
</button>
<% else %>
<button class="menu-editor-inline-action" type="submit">
<%= translated("menuEditor.addCategoryArchive") %>
</button>
<% end %>
<button class="menu-editor-inline-action" type="button" phx-click="cancel_menu_editor_entry">
<%= translated("Cancel") %>
</button>
</div>
</div>
<%= if @menu_editor.draft.type == :page do %>
<div class="menu-editor-picker-list">
<%= if @menu_editor.filtered_pages == [] do %>
<div class="menu-editor-picker-state"><%= translated("menuEditor.pagePicker.empty") %></div>
<% else %>
<%= for post <- @menu_editor.filtered_pages do %>
<button
class="menu-editor-picker-item"
type="button"
phx-click="select_menu_editor_page"
phx-value-post_id={post.id}
>
<span><%= post.title %></span>
<small><%= post.slug %></small>
</button>
<% end %>
<% end %>
</div>
<% else %>
<div class="menu-editor-picker-list">
<%= if @menu_editor.filtered_categories == [] do %>
<div class="menu-editor-picker-state"><%= translated("menuEditor.categoryPicker.empty") %></div>
<% else %>
<%= for category <- @menu_editor.filtered_categories do %>
<button
class="menu-editor-picker-item"
type="button"
phx-click="select_menu_editor_category"
phx-value-name={category.name}
>
<span><%= category.title %></span>
<small><%= category.name %></small>
</button>
<% end %>
<% end %>
</div>
<% end %>
</div>
</form>
</div>
<% else %>
<span class="menu-editor-row-title"><%= row_label(item, @menu_editor.category_titles) %></span>
<% end %>
</div>
<%= if item.children != [] do %>
<ul class="menu-editor-tree-level">
<.menu_tree_level items={item.children} menu_editor={@menu_editor} depth={@depth + 1} />
</ul>
<% end %>
</li>
<% end %>
"""
end
attr :kind, :atom, required: true
def kind_icon(assigns) do
~H"""
<%= case @kind do %>
<% :home -> %>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 2 2 7v7h4V9h4v5h4V7L8 2z" /></svg>
<% :page -> %>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M3 2h7l3 3v9H3V2zm7 1.5V6h2.5L10 3.5z" /></svg>
<% :category_archive -> %>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 3h12v3H2V3zm1 4h10v6H3V7zm2 1v1h6V8H5z" /></svg>
<% _other -> %>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 3h12v2H2V3zm0 4h12v2H2V7zm0 4h12v2H2v-2z" /></svg>
<% end %>
"""
end
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
def row_label(item, category_titles) do
if item.kind == :category_archive do
Map.get(category_titles || %{}, item.slug, item.label)
else
item.label
end
end
def kind_label(:home), do: translated("menuEditor.type.home")
def kind_label(:page), do: translated("menuEditor.type.page")
def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive")
def kind_label(:submenu), do: translated("menuEditor.type.submenu")
def draft_item?(menu_editor, item_id) do
match?(%{item_id: ^item_id}, menu_editor.draft)
end
def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive")
def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title")
def editing_hint(%{draft: %{type: :category}}), do: translated("menuEditor.categoryPicker.hint")
def editing_hint(_menu_editor), do: translated("menuEditor.createHint")
def editing_placeholder(%{draft: %{type: :category}}), do: translated("menuEditor.newCategoryPlaceholder")
def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder")
defp ensure_state(assigns) do
project_id = assigns.projects.active_project_id
case assigns[:menu_editor_state] do
%{project_id: ^project_id} = state -> state
_other -> load_state(project_id)
end
end
defp load_state(nil) do
%{project_id: nil, items: [home_item()], selected_id: @home_item_id, draft: nil}
end
defp load_state(project_id) do
{:ok, %{items: items}} = Menu.get_menu(project_id)
items = Enum.map(items, &ui_item/1)
%{
project_id: project_id,
items: items,
selected_id: first_item_id(items),
draft: nil
}
end
defp build(_assigns, state) do
categories = category_options(state.project_id)
draft = state.draft
draft_query = Map.get(draft || %{}, :query, "")
%{
title: translated("menuEditor.title"),
description: translated("menuEditor.description"),
items: state.items,
selected_id: state.selected_id,
draft: draft,
draft_query: draft_query,
filtered_pages:
if(match?(%{type: :page}, draft),
do: filter_page_posts(page_posts(state.project_id), draft_query),
else: []
),
filtered_categories:
if(match?(%{type: :category}, draft),
do: filter_categories(categories, draft_query),
else: []
),
category_titles: Map.new(categories, &{&1.name, &1.title}),
can_move_up?: can_move_up?(state.items, state.selected_id),
can_move_down?: can_move_down?(state.items, state.selected_id),
can_indent?: can_indent?(state.items, state.selected_id),
can_unindent?: can_unindent?(state.items, state.selected_id),
can_delete?: can_delete?(state.selected_id),
has_items?: state.items != []
}
end
defp save(socket, reload, append_output) do
state = socket.assigns.menu_editor_state
{:ok, _menu} = Menu.update_menu(state.project_id, Enum.map(state.items, &persisted_item/1))
socket
|> append_output.(translated("menuEditor.tabTitle"), translated("menuEditor.saved"), nil, "info")
|> reload.(socket.assigns.workbench)
end
defp confirm_category_draft(socket) do
project_id = socket.assigns.projects.active_project_id
draft = current_draft(socket.assigns)
normalized = String.trim(Map.get(draft || %{}, :query, ""))
category =
Enum.find(category_options(project_id), fn option ->
String.downcase(option.name) == String.downcase(normalized) or
String.downcase(option.title) == String.downcase(normalized)
end)
category =
cond do
category != nil -> category
normalized == "" -> %{name: "", title: ""}
true ->
{:ok, _metadata} = Metadata.add_category(project_id, normalized)
%{name: normalized, title: normalized}
end
update_state(socket, &assign_category_to_draft(&1, category))
end
defp update_state(socket, updater) do
state = ensure_state(socket.assigns)
assign(socket, :menu_editor_state, updater.(state))
end
defp start_page_draft(state) do
item = %{item_id: Ecto.UUID.generate(), kind: :page, label: translated("menuEditor.newPage"), slug: nil, children: [], is_home: false}
{parent_path, index} = insert_target(state.items, state.selected_id)
items = insert_item(state.items, parent_path, index, item)
%{state | items: items, selected_id: item.item_id, draft: %{item_id: item.item_id, type: :page, query: ""}}
end
defp start_category_draft(state) do
item = %{item_id: Ecto.UUID.generate(), kind: :category_archive, label: "", slug: nil, children: [], is_home: false}
{parent_path, index} = insert_target(state.items, state.selected_id)
items = insert_item(state.items, parent_path, index, item)
%{state | items: items, selected_id: item.item_id, draft: %{item_id: item.item_id, type: :category, query: ""}}
end
defp finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do
label = if(String.trim(query) == "", do: translated("menuEditor.newSubmenu"), else: String.trim(query))
%{state | items: update_item(state.items, item_id, fn item -> %{item | kind: :submenu, label: label, slug: nil, children: item.children || []} end), draft: nil}
end
defp finalize_submenu_draft(state), do: state
defp assign_page_to_draft(%{draft: %{item_id: item_id}} = state, post) do
%{
state
| items:
update_item(state.items, item_id, fn item ->
%{item | kind: :page, label: post.title, slug: blank_to_nil(post.slug), children: []}
end),
draft: nil
}
end
defp assign_page_to_draft(state, _post), do: state
defp assign_category_to_draft(%{draft: %{item_id: item_id}} = state, category) do
label = blank_to_nil(category.title) || category.name
%{
state
| items:
update_item(state.items, item_id, fn item ->
%{item | kind: :category_archive, label: label, slug: category.name, children: []}
end),
draft: nil
}
end
defp assign_category_to_draft(state, _category), do: state
defp cancel_draft(%{draft: %{item_id: item_id}} = state) do
items = remove_item(state.items, item_id)
%{state | items: items, selected_id: first_item_id(items), draft: nil}
end
defp cancel_draft(state), do: state
defp move_selected(%{selected_id: selected_id} = state, direction) when direction in [:up, :down] do
case find_path(state.items, selected_id) do
nil -> state
[] -> state
path ->
parent_path = Enum.drop(path, -1)
index = List.last(path)
siblings = items_at_path(state.items, parent_path)
delta = if(direction == :up, do: -1, else: 1)
target_index = index + delta
if target_index < 0 or target_index >= length(siblings) do
state
else
reordered =
List.pop_at(siblings, index)
|> then(fn {item, rest} -> List.insert_at(rest, target_index, item) end)
%{state | items: replace_items_at_path(state.items, parent_path, reordered)}
end
end
end
defp indent_selected(%{selected_id: selected_id} = state) do
case find_path(state.items, selected_id) do
nil -> state
[] -> state
path ->
parent_path = Enum.drop(path, -1)
index = List.last(path)
cond do
index <= 0 ->
state
true ->
previous_sibling_path = parent_path ++ [index - 1]
case item_at_path(state.items, previous_sibling_path) do
%{kind: :submenu, item_id: sibling_id} ->
case remove_item_with_value(state.items, selected_id) do
{_next_items, nil} -> state
{next_items, removed_item} ->
%{
state
| items: append_child(next_items, sibling_id, removed_item)
}
end
_other ->
state
end
end
end
end
defp unindent_selected(%{selected_id: selected_id} = state) do
case find_path(state.items, selected_id) do
nil -> state
[] -> state
[_root_index] -> state
path ->
parent_path = Enum.drop(path, -1)
parent_index = List.last(parent_path)
grand_parent_path = Enum.drop(parent_path, -1)
case remove_item_with_value(state.items, selected_id) do
{_next_items, nil} -> state
{next_items, removed_item} ->
%{
state
| items: insert_item(next_items, grand_parent_path, parent_index + 1, removed_item)
}
end
end
end
defp delete_selected(%{selected_id: @home_item_id} = state), do: state
defp delete_selected(%{selected_id: selected_id} = state) do
items = remove_item(state.items, selected_id)
%{state | items: items, selected_id: first_item_id(items), draft: nil}
end
defp drop_selected(state, drag_item_id, target_item_id, _position)
when drag_item_id in [nil, ""] or target_item_id in [nil, ""] do
state
end
defp drop_selected(state, drag_item_id, target_item_id, _position) when drag_item_id == target_item_id,
do: state
defp drop_selected(state, drag_item_id, target_item_id, position) do
drag_path = find_path(state.items, drag_item_id)
target_path = find_path(state.items, target_item_id)
cond do
is_nil(drag_path) or is_nil(target_path) ->
state
path_prefix?(drag_path, target_path) ->
state
true ->
case remove_item_with_value(state.items, drag_item_id) do
{_next_items, nil} ->
state
{next_items, dragged_item} ->
case find_path(next_items, target_item_id) do
nil ->
state
next_target_path ->
insert_dropped_item(state, next_items, dragged_item, next_target_path, position)
end
end
end
end
defp insert_dropped_item(state, next_items, dragged_item, target_path, "inside") do
case item_at_path(next_items, target_path) do
%{kind: :submenu} ->
%{state | items: insert_item(next_items, target_path, 0, dragged_item), selected_id: dragged_item.item_id}
_other ->
state
end
end
defp insert_dropped_item(state, next_items, dragged_item, target_path, "before") do
parent_path = Enum.drop(target_path, -1)
index = List.last(target_path)
%{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id}
end
defp insert_dropped_item(state, next_items, dragged_item, target_path, _position) do
parent_path = Enum.drop(target_path, -1)
index = List.last(target_path) + 1
%{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id}
end
defp current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft)
defp page_posts(nil), do: []
defp page_posts(project_id) do
Repo.all(from post in Post, where: post.project_id == ^project_id, order_by: [asc: post.title, asc: post.slug])
|> Enum.filter(&("page" in (&1.categories || [])))
end
defp page_post(nil, _post_id), do: nil
defp page_post(project_id, post_id) do
Enum.find(page_posts(project_id), &(&1.id == post_id))
end
defp filter_page_posts(posts, query) do
normalized = query |> to_string() |> String.trim() |> String.downcase()
Enum.filter(posts, fn post ->
normalized == "" or
String.contains?(String.downcase(post.title || ""), normalized) or
String.contains?(String.downcase(post.slug || ""), normalized)
end)
end
defp category_options(nil), do: []
defp category_options(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
Enum.map(metadata.categories || [], fn name ->
title = get_in(metadata.category_settings || %{}, [name, "title"])
%{name: name, title: blank_to_nil(title) || name}
end)
end
defp filter_categories(categories, query) do
normalized = query |> to_string() |> String.trim() |> String.downcase()
Enum.filter(categories, fn category ->
normalized == "" or
String.contains?(String.downcase(category.name), normalized) or
String.contains?(String.downcase(category.title), normalized)
end)
end
defp can_move_up?(items, selected_id) do
case find_path(items, selected_id) do
[_parent, index] -> index > 0
[index] -> index > 0
path when is_list(path) -> List.last(path) > 0
_other -> false
end
end
defp can_move_down?(items, selected_id) do
case find_path(items, selected_id) do
nil -> false
path ->
parent_path = Enum.drop(path, -1)
index = List.last(path)
index < length(items_at_path(items, parent_path)) - 1
end
end
defp can_indent?(items, selected_id) do
case find_path(items, selected_id) do
nil -> false
[] -> false
[_index] = path ->
index = List.last(path)
index > 0 and match?(%{kind: :submenu}, item_at_path(items, [index - 1]))
path ->
index = List.last(path)
index > 0 and
match?(%{kind: :submenu}, item_at_path(items, Enum.drop(path, -1) ++ [index - 1]))
end
end
defp can_unindent?(items, selected_id) do
case find_path(items, selected_id) do
[_index] -> false
path when is_list(path) -> length(path) > 1
_other -> false
end
end
defp can_delete?(selected_id), do: is_binary(selected_id) and selected_id != @home_item_id
defp home_item do
%{item_id: @home_item_id, kind: :home, label: "Home", slug: nil, children: [], is_home: true}
end
defp ui_item(%{kind: :home}), do: home_item()
defp ui_item(item) do
kind = Map.get(item, :kind, :page)
%{
item_id: Ecto.UUID.generate(),
kind: kind,
label: Map.get(item, :label, ""),
slug: Map.get(item, :slug),
children: Enum.map(Map.get(item, :children, []), &ui_item/1),
is_home: false
}
end
defp persisted_item(%{kind: :home}), do: %{kind: :home, label: "Home", slug: nil}
defp persisted_item(%{kind: :submenu} = item) do
%{kind: :submenu, label: item.label, slug: nil, children: Enum.map(item.children || [], &persisted_item/1)}
end
defp persisted_item(item) do
%{kind: item.kind, label: item.label, slug: item.slug}
end
defp insert_target(items, nil), do: {[], length(items)}
defp insert_target(items, selected_id) do
case find_path(items, selected_id) do
nil -> {[], length(items)}
[] -> {[], length(items)}
path ->
case item_at_path(items, path) do
%{kind: :submenu} -> {path, 0}
_other -> {Enum.drop(path, -1), List.last(path) + 1}
end
end
end
defp first_item_id([item | _rest]), do: item.item_id
defp first_item_id([]), do: nil
defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do
trimmed = String.trim(to_string(value))
if trimmed == "", do: nil, else: trimmed
end
defp path_prefix?(prefix, path) when length(prefix) > length(path), do: false
defp path_prefix?(prefix, path), do: Enum.take(path, length(prefix)) == prefix
defp find_path(items, item_id, path \\ []) do
Enum.find_value(Enum.with_index(items), fn {item, index} ->
next_path = path ++ [index]
cond do
item.item_id == item_id ->
next_path
item.children != [] ->
find_path(item.children, item_id, next_path)
true ->
nil
end
end)
end
defp item_at_path(_items, []), do: nil
defp item_at_path(items, [index]) do
Enum.at(items, index)
end
defp item_at_path(items, [index | rest]) do
case Enum.at(items, index) do
nil -> nil
item -> item_at_path(item.children || [], rest)
end
end
defp items_at_path(items, []), do: items
defp items_at_path(items, [index | rest]) do
case Enum.at(items, index) do
nil -> []
item -> items_at_path(item.children || [], rest)
end
end
defp replace_items_at_path(_items, [], replacement), do: replacement
defp replace_items_at_path(items, [index | rest], replacement) do
List.update_at(items, index, fn item ->
%{item | children: replace_items_at_path(item.children || [], rest, replacement)}
end)
end
defp update_item(items, item_id, updater) do
Enum.map(items, fn item ->
cond do
item.item_id == item_id -> updater.(item)
item.children != [] -> %{item | children: update_item(item.children, item_id, updater)}
true -> item
end
end)
end
defp insert_item(items, [], index, item) do
List.insert_at(items, index, item)
end
defp insert_item(items, [head | tail], index, item) do
List.update_at(items, head, fn current ->
%{current | children: insert_item(current.children || [], tail, index, item)}
end)
end
defp remove_item(items, item_id) do
remove_item_with_value(items, item_id) |> elem(0)
end
defp remove_item_with_value(items, item_id) do
Enum.reduce_while(Enum.with_index(items), {items, nil}, fn {item, index}, _acc ->
cond do
item.item_id == item_id ->
{:halt, {List.delete_at(items, index), item}}
item.children != [] ->
{next_children, removed_item} = remove_item_with_value(item.children, item_id)
if removed_item do
{:halt, {List.replace_at(items, index, %{item | children: next_children}), removed_item}}
else
{:cont, {items, nil}}
end
true ->
{:cont, {items, nil}}
end
end)
end
defp append_child(items, parent_item_id, child) do
update_item(items, parent_item_id, fn item ->
%{item | children: (item.children || []) ++ [child]}
end)
end
end

View File

@@ -0,0 +1,56 @@
<div class="menu-editor-view" data-testid="menu-editor" phx-window-keydown={if(@menu_editor.draft, do: "menu_editor_keydown")}>
<div class="menu-editor-header">
<div>
<h2><%= @menu_editor.title %></h2>
<p><%= @menu_editor.description %></p>
</div>
</div>
<div class="menu-editor-main">
<div class="menu-editor-tree-wrap">
<div class="menu-editor-toolbar" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-entry" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-entry" title={translated("menuEditor.addEntry")}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5V2z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="save" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="save" title={translated("menuEditor.save")}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-category-archive" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-category-archive" title={translated("menuEditor.addCategoryArchive")}>
<span aria-hidden="true"><%= translated("menuEditor.addCategoryArchiveShort") %></span>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-up" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-up" title={translated("menuEditor.moveUp")} disabled={not @menu_editor.can_move_up?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-down" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-down" title={translated("menuEditor.moveDown")} disabled={not @menu_editor.can_move_down?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="indent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="indent" title={translated("menuEditor.indent")} disabled={not @menu_editor.can_indent?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm6-1 3 2-3 2V9z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="unindent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="unindent" title={translated("menuEditor.unindent")} disabled={not @menu_editor.can_unindent?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm3-1-3 2 3 2V9z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="delete" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="delete" title={translated("menuEditor.delete")} disabled={not @menu_editor.can_delete?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
</button>
</div>
<%= if @menu_editor.items == [] do %>
<div class="menu-editor-empty"><%= translated("menuEditor.empty") %></div>
<% else %>
<div id="menu-editor-tree-shell" class="menu-editor-tree-shell" phx-hook="MenuEditorTree">
<ul class="menu-editor-tree-level">
<.menu_tree_level items={@menu_editor.items} menu_editor={@menu_editor} depth={0} />
</ul>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -9,7 +9,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
alias BDS.{I18n, Metadata, Repo} alias BDS.{I18n, Metadata, Repo}
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.Media.Translation, as: MediaTranslation alias BDS.Media.Translation, as: MediaTranslation
alias BDS.Posts.{Post, Translation} alias BDS.Posts.{Post, PostMedia, Translation}
alias BDS.Tags.Tag alias BDS.Tags.Tag
embed_templates "overlay_html/*" embed_templates "overlay_html/*"
@@ -112,10 +112,12 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
end end
defp post_media_ids(%{type: :post, id: post_id}) do defp post_media_ids(%{type: :post, id: post_id}) do
case Repo.query("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do Repo.all(
{:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end) from pm in PostMedia,
_other -> [] where: pm.post_id == ^post_id,
end order_by: [asc: pm.sort_order, asc: pm.media_id],
select: pm.media_id
)
rescue rescue
_error -> [] _error -> []
end end
@@ -229,10 +231,15 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
end end
reference_list = reference_list =
case Repo.query("SELECT posts.title FROM posts JOIN post_media ON posts.id = post_media.post_id WHERE post_media.media_id = ? ORDER BY post_media.sort_order ASC, posts.updated_at DESC", [media_id]) do Repo.all(
{:ok, %{rows: rows}} -> Enum.map(rows, fn [title] -> title || media_id end) from post in Post,
_other -> [] join: pm in PostMedia,
end on: pm.post_id == post.id,
where: pm.media_id == ^media_id,
order_by: [asc: pm.sort_order, desc: post.updated_at],
select: post.title
)
|> Enum.map(&(&1 || media_id))
%{ %{
title: ShellData.translate("Delete Media", %{}, page_language), title: ShellData.translate("Delete Media", %{}, page_language),

View File

@@ -8,7 +8,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.{AI, I18n, Metadata, PostLinks, Posts, Preview, Repo, Tags, Templates} alias BDS.{AI, I18n, Metadata, PostLinks, Posts, Preview, Repo, Tags, Templates}
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.Posts.{Post, Translation} alias BDS.Posts.{Post, PostMedia, Translation}
alias BDS.UI.Workbench alias BDS.UI.Workbench
embed_templates "post_editor_html/*" embed_templates "post_editor_html/*"
@@ -770,27 +770,29 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
defp linked_media(post_id) do defp linked_media(post_id) do
case Repo.query("SELECT media_id, sort_order FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do rows =
{:ok, %{rows: rows}} -> Repo.all(
Enum.map(rows, fn [media_id, sort_order] -> from pm in PostMedia,
case Repo.get(Media, media_id) do where: pm.post_id == ^post_id,
%Media{} = media -> order_by: [asc: pm.sort_order, asc: pm.media_id],
%{ select: {pm.media_id, pm.sort_order}
media_id: media.id, )
has_thumbnail: String.starts_with?(to_string(media.mime_type || ""), "image/"),
name: media.title || media.original_name || media.id,
sort_order: sort_order || 0
}
_other -> Enum.map(rows, fn {media_id, sort_order} ->
nil case Repo.get(Media, media_id) do
end %Media{} = media ->
end) %{
|> Enum.reject(&is_nil/1) media_id: media.id,
has_thumbnail: String.starts_with?(to_string(media.mime_type || ""), "image/"),
name: media.title || media.original_name || media.id,
sort_order: sort_order || 0
}
_other -> _other ->
[] nil
end end
end)
|> Enum.reject(&is_nil/1)
rescue rescue
_error -> [] _error -> []
end end

View File

@@ -2,22 +2,49 @@ defmodule BDS.Generation do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
import BDS.Generation.Paths,
except: [post_output_path: 1, post_output_path: 2]
import BDS.Generation.Sitemap,
only: [
render: 1,
render_multi_language: 6,
render_feed: 3,
render_atom: 3,
render_calendar: 1,
extract_locs: 1,
loc_to_project_path: 2
]
import BDS.Generation.Renderers
import BDS.Generation.Progress
alias BDS.DocumentFields alias BDS.DocumentFields
alias BDS.Frontmatter alias BDS.Frontmatter
alias BDS.Generation.GeneratedFileHash alias BDS.Generation.GeneratedFileHash
alias BDS.Generation.Paths
alias BDS.Metadata alias BDS.Metadata
alias BDS.Persistence alias BDS.Persistence
alias BDS.PreviewAssets alias BDS.PreviewAssets
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.Translation alias BDS.Posts.Translation
alias BDS.Projects alias BDS.Projects
alias BDS.Rendering
alias BDS.Repo alias BDS.Repo
alias BDS.Slug alias BDS.Slug
@core_sections [:core, :single, :category, :tag, :date] @core_sections [:core, :single, :category, :tag, :date]
@typedoc "A section identifier accepted by `generate_site/3` and friends."
@type section :: :core | :single | :category | :tag | :date
@typedoc "Options accepted by long-running generation operations."
@type generation_opts :: keyword()
@typedoc "Plan returned by `plan_generation/2`."
@type plan :: map()
@typedoc "Validation report returned by `validate_site/3`."
@type validation_report :: map()
@spec plan_generation(String.t(), [section()]) :: {:ok, plan()}
def plan_generation(project_id, sections \\ [:core]) def plan_generation(project_id, sections \\ [:core])
when is_binary(project_id) and is_list(sections) do when is_binary(project_id) and is_list(sections) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
@@ -40,13 +67,15 @@ defmodule BDS.Generation do
}} }}
end end
@spec generate_site(String.t(), [section()], generation_opts()) ::
{:ok, %{sections: [section()], generated_files: [map()]}} | {:error, term()}
def generate_site(project_id, sections \\ [:core], opts \\ []) def generate_site(project_id, sections \\ [:core], opts \\ [])
def generate_site(project_id, sections, opts) def generate_site(project_id, sections, opts)
when is_binary(project_id) and is_list(sections) and is_list(opts) do when is_binary(project_id) and is_list(sections) and is_list(opts) do
with {:ok, plan} <- plan_generation(project_id, sections) do with {:ok, plan} <- plan_generation(project_id, sections) do
outputs = build_outputs(plan) outputs = build_outputs(plan)
on_progress = progress_callback(opts) on_progress = callback(opts)
total_outputs = length(outputs) total_outputs = length(outputs)
:ok = report_generation_started(on_progress, total_outputs, "generated files") :ok = report_generation_started(on_progress, total_outputs, "generated files")
@@ -63,11 +92,13 @@ defmodule BDS.Generation do
end end
end end
@spec validate_site(String.t(), [section()], generation_opts()) ::
{:ok, validation_report()} | {:error, term()}
def validate_site(project_id, sections \\ @core_sections, opts \\ []) def validate_site(project_id, sections \\ @core_sections, opts \\ [])
def validate_site(project_id, sections, opts) when is_binary(project_id) and is_list(sections) and is_list(opts) do def validate_site(project_id, sections, opts) when is_binary(project_id) and is_list(sections) and is_list(opts) do
with {:ok, plan} <- plan_generation(project_id, sections) do with {:ok, plan} <- plan_generation(project_id, sections) do
on_progress = progress_callback(opts) on_progress = callback(opts)
:ok = report_validation_progress(on_progress, 0.0, "Collecting sitemap URLs...") :ok = report_validation_progress(on_progress, 0.0, "Collecting sitemap URLs...")
data = data =
@@ -128,67 +159,7 @@ defmodule BDS.Generation do
end end
end end
defp progress_callback(opts) do @spec apply_validation(String.t(), [section()] | map()) :: {:ok, map()} | {:error, term()}
case Keyword.get(opts, :on_progress) do
callback when is_function(callback, 2) -> callback
_other -> nil
end
end
defp report_generation_started(nil, _total, _label), do: :ok
defp report_generation_started(callback, 0, label) do
callback.(1.0, "No #{label} to process")
:ok
end
defp report_generation_started(callback, total, label) do
callback.(0.0, "Processing 0/#{total} #{label}")
:ok
end
defp report_generation_progress(nil, _current, _total, _label), do: :ok
defp report_generation_progress(_callback, _current, 0, _label), do: :ok
defp report_generation_progress(callback, current, total, label) do
callback.(current / total, "Processing #{current}/#{total} #{label}")
:ok
end
defp report_validation_progress(nil, _progress, _message), do: :ok
defp report_validation_progress(callback, progress, message) do
callback.(progress, message)
:ok
end
defp report_validation_snapshot_progress(nil, _stage, _current, _total), do: :ok
defp report_validation_snapshot_progress(_callback, _stage, _current, total)
when total <= 0,
do: :ok
defp 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
end
defp 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
end
defp report_validation_collection_progress(nil, _current, _total), do: :ok
defp report_validation_collection_progress(_callback, _current, total) when total <= 0, do: :ok
defp 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
end
def apply_validation(project_id, sections) when is_binary(project_id) and is_list(sections) do def apply_validation(project_id, sections) when is_binary(project_id) and is_list(sections) do
with {:ok, plan} <- plan_generation(project_id, sections) do with {:ok, plan} <- plan_generation(project_id, sections) do
expected_outputs = build_outputs(plan) expected_outputs = build_outputs(plan)
@@ -283,26 +254,20 @@ defmodule BDS.Generation do
end end
end end
def post_output_path(post), do: post_output_path(post, nil) @spec post_output_path(map()) :: String.t()
defdelegate post_output_path(post), to: Paths
def post_output_path(post, language) when is_map(post) do @spec post_output_path(map(), String.t() | nil) :: String.t()
{year, month, day} = local_date_parts!(post.created_at) defdelegate post_output_path(post, language), to: Paths
year = Integer.to_string(year)
month = month |> Integer.to_string() |> String.pad_leading(2, "0")
day = day |> Integer.to_string() |> String.pad_leading(2, "0")
path_parts = [year, month, day, post.slug, "index.html"] @typedoc "Result returned by `write_generated_file/3,4`."
@type write_result :: %{relative_path: String.t(), content_hash: String.t(), written?: boolean()}
case language do
nil -> Path.join(path_parts)
"" -> Path.join(path_parts)
value -> Path.join([value | path_parts])
end
end
@spec write_generated_file(String.t(), String.t(), String.t()) :: {:ok, write_result()}
def write_generated_file(project_id, relative_path, content), def write_generated_file(project_id, relative_path, content),
do: write_generated_file(project_id, relative_path, content, []) do: write_generated_file(project_id, relative_path, content, [])
@spec write_generated_file(String.t(), String.t(), String.t(), keyword()) :: {:ok, write_result()}
def write_generated_file(project_id, relative_path, content, opts) def write_generated_file(project_id, relative_path, content, opts)
when is_binary(project_id) and is_binary(relative_path) and is_binary(content) and is_list(opts) do when is_binary(project_id) and is_binary(relative_path) and is_binary(content) and is_list(opts) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
@@ -335,6 +300,7 @@ defmodule BDS.Generation do
end end
end end
@spec list_generated_files(String.t()) :: {:ok, [map()]}
def list_generated_files(project_id) when is_binary(project_id) do def list_generated_files(project_id) when is_binary(project_id) do
{:ok, {:ok,
Repo.all( Repo.all(
@@ -344,6 +310,7 @@ defmodule BDS.Generation do
)} )}
end end
@spec delete_generated_file(String.t(), String.t()) :: :ok | {:error, term()}
def delete_generated_file(project_id, relative_path) def delete_generated_file(project_id, relative_path)
when is_binary(project_id) and is_binary(relative_path) do when is_binary(project_id) and is_binary(relative_path) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
@@ -737,14 +704,14 @@ defmodule BDS.Generation do
sitemap = sitemap =
if :core in plan.sections do if :core in plan.sections do
[{"sitemap.xml", render_sitemap(urls)}] [{"sitemap.xml", render(urls)}]
else else
[] []
end end
pagefind_outputs = pagefind_outputs =
if :core in plan.sections do if :core in plan.sections do
build_pagefind_outputs(plan, core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs) BDS.Generation.Pagefind.build_outputs(plan, core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs)
else else
[] []
end end
@@ -800,7 +767,7 @@ defmodule BDS.Generation do
sitemap_content = sitemap_content =
main_paths main_paths
|> Enum.map(&url_for_output(plan.base_url, &1)) |> Enum.map(&url_for_output(plan.base_url, &1))
|> render_sitemap() |> render()
additional_expected_paths = additional_expected_paths =
additional_language_sets additional_language_sets
@@ -823,7 +790,7 @@ defmodule BDS.Generation do
[] -> sitemap_content [] -> sitemap_content
languages -> languages ->
render_multi_language_sitemap( render_multi_language(
plan, plan,
Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate))), Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate))),
Enum.filter(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate))), Enum.filter(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate))),
@@ -960,8 +927,6 @@ defmodule BDS.Generation do
end) end)
end end
defp truthy_flag?(value), do: value not in [false, nil]
defp disk_generated_files(project_id) do defp disk_generated_files(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
html_root = output_path(project, "") html_root = output_path(project, "")
@@ -1294,91 +1259,6 @@ defmodule BDS.Generation do
end) end)
end end
defp paginated_archive_paths(route_language, segments, total_items, max_posts_per_page) do
total_pages = page_count(total_items, max_posts_per_page)
Enum.map(1..total_pages, fn page_number ->
archive_path(route_language, segments, page_number)
end)
end
defp root_route_paths(route_language, total_items, max_posts_per_page) do
total_pages = page_count(total_items, max_posts_per_page)
Enum.map(1..total_pages, fn page_number ->
root_output_path(route_language, page_number)
end)
end
defp root_output_path(nil, 1), do: "index.html"
defp root_output_path("", 1), do: "index.html"
defp root_output_path(route_language, 1), do: Path.join(route_language, "index.html")
defp root_output_path(nil, page_number), do: Path.join(["page", Integer.to_string(page_number), "index.html"])
defp root_output_path("", page_number), do: root_output_path(nil, page_number)
defp root_output_path(route_language, page_number), do: Path.join([route_language, "page", Integer.to_string(page_number), "index.html"])
defp page_output_path(slug, nil), do: Path.join([slug, "index.html"])
defp page_output_path(slug, ""), do: page_output_path(slug, nil)
defp page_output_path(slug, language), do: Path.join([language, slug, "index.html"])
defp pagination_for_page(page_number, total_pages, total_items, items_per_page, route_language, segments) do
%{
current_page: page_number,
total_pages: total_pages,
total_items: total_items,
items_per_page: items_per_page,
has_prev_page: page_number > 1,
prev_page_href: archive_or_root_href(route_language, segments, page_number - 1),
has_next_page: page_number < total_pages,
next_page_href: archive_or_root_href(route_language, segments, page_number + 1)
}
end
defp archive_or_root_href(_route_language, _segments, page_number) when page_number < 1, do: ""
defp archive_or_root_href(route_language, [], page_number), do: root_page_href(route_language, page_number)
defp archive_or_root_href(route_language, segments, page_number), do: archive_href(route_language, segments, page_number)
defp root_page_href(route_language, page_number) when page_number <= 1 do
case route_language do
nil -> "/"
"" -> "/"
language -> "/#{language}/"
end
end
defp root_page_href(route_language, page_number) do
base =
case route_language do
nil -> ""
"" -> ""
language -> "/#{language}"
end
"#{base}/page/#{page_number}/"
end
defp page_count(total_items, _max_posts_per_page) when total_items <= 0, do: 1
defp page_count(total_items, max_posts_per_page) do
page_size = max(max_posts_per_page, 1)
div(total_items + page_size - 1, page_size)
end
defp paginate_posts(posts, max_posts_per_page) do
case Enum.chunk_every(posts, max(max_posts_per_page, 1)) do
[] -> [[]]
chunks -> chunks
end
end
defp report_snapshot_stage_progress(nil, _stage, _current, _total), do: :ok
defp report_snapshot_stage_progress(_callback, _stage, _current, total) when total <= 0, do: :ok
defp report_snapshot_stage_progress(callback, stage, current, total) do
callback.(stage, current, total)
:ok
end
defp build_single_outputs( defp build_single_outputs(
project_id, project_id,
main_language, main_language,
@@ -1453,35 +1333,6 @@ defmodule BDS.Generation do
end end
end end
defp archive_path(language, segments, 1), do: archive_path(language, segments)
defp archive_path(language, segments, page_number) do
archive_path(language, segments ++ ["page", Integer.to_string(page_number)])
end
defp archive_path(nil, segments), do: Path.join(segments ++ ["index.html"])
defp archive_path("", segments), do: Path.join(segments ++ ["index.html"])
defp archive_path(language, segments) do
prefix = if language in [nil, ""], do: [], else: [language]
Path.join(prefix ++ segments ++ ["index.html"])
end
defp archive_route_segment(nil), do: ""
defp archive_route_segment(value), do: value |> to_string() |> URI.encode(&URI.char_unreserved?/1)
defp normalize_base_url(nil), do: nil
defp normalize_base_url(url), do: String.trim_trailing(url, "/")
defp normalize_blog_languages(main_language, blog_languages) do
([main_language] ++ (blog_languages || []))
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq()
end
defp route_language(main_language, language) when main_language == language, do: nil
defp route_language(_main_language, language), do: language
defp translation_lookup_map(published_translations) do defp translation_lookup_map(published_translations) do
Map.new(published_translations, fn translation -> Map.new(published_translations, fn translation ->
{{translation.translation_for, translation.language}, translation} {{translation.translation_for, translation.language}, translation}
@@ -1526,519 +1377,6 @@ defmodule BDS.Generation do
} }
end end
defp render_home(plan, language) do
[
"<html>",
"<head><title>",
plan.project_name,
"</title></head>",
"<body data-language=\"",
to_string(language),
"\"><main><h1>",
plan.project_name,
"</h1></main></body>",
"</html>"
]
|> IO.iodata_to_binary()
end
defp render_feed(plan, language, published_posts) do
items =
published_posts
|> Enum.filter(&(&1.language == language or language == plan.language))
|> Enum.map(fn post ->
"<item><title>#{xml_escape(post.title)}</title><link>#{url_for_output(plan.base_url, post_output_path(post))}</link></item>"
end)
|> Enum.join()
"<rss><channel><title>#{xml_escape(plan.project_name)} (#{xml_escape(language || "default")})</title>#{items}</channel></rss>"
end
defp render_atom(plan, language, published_posts) do
entries =
published_posts
|> Enum.filter(&(&1.language == language or language == plan.language))
|> Enum.map(fn post ->
"<entry><title>#{xml_escape(post.title)}</title><id>#{url_for_output(plan.base_url, post_output_path(post))}</id></entry>"
end)
|> Enum.join()
"<feed><title>#{xml_escape(plan.project_name)} (#{xml_escape(language || "default")})</title>#{entries}</feed>"
end
defp render_calendar(published_posts) do
published_posts
|> Enum.map(fn post ->
%{date: local_date_iso8601!(post.created_at), slug: post.slug, title: post.title}
end)
|> Jason.encode!()
end
defp render_sitemap(urls) do
entries = Enum.map_join(urls, "", fn url -> "<url><loc>#{xml_escape(url)}</loc></url>" end)
"<urlset>#{entries}</urlset>"
end
defp render_multi_language_sitemap(
plan,
translatable_posts,
do_not_translate_posts,
published_list_posts,
post_index,
additional_languages
) do
all_languages = [plan.language | additional_languages]
latest_post_updated_at = latest_post_updated_at_iso(published_list_posts)
urls =
[
render_multi_language_sitemap_url(
url_for_path(plan.base_url, "/"),
latest_post_updated_at,
"daily",
"1.0",
build_hreflang_links(plan.base_url, "/", plan.language, all_languages)
)
] ++
Enum.map(root_pagination_pages(length(published_list_posts), plan.max_posts_per_page), fn page_number ->
page_path = "/page/#{page_number}"
render_multi_language_sitemap_url(
url_for_path(plan.base_url, page_path),
latest_post_updated_at,
"daily",
"0.9",
build_hreflang_links(plan.base_url, page_path, plan.language, all_languages)
)
end) ++
Enum.map(translatable_posts, fn post ->
post_path = relative_path_to_url_path(post_output_path(post))
render_multi_language_sitemap_url(
url_for_path(plan.base_url, post_path),
unix_ms_to_iso8601(post.updated_at),
"monthly",
"0.8",
build_hreflang_links(plan.base_url, post_path, plan.language, all_languages)
)
end) ++
Enum.map(do_not_translate_posts, fn post ->
post_path = relative_path_to_url_path(post_output_path(post))
render_multi_language_sitemap_url(
url_for_path(plan.base_url, post_path),
unix_ms_to_iso8601(post.updated_at),
"monthly",
"0.8",
build_hreflang_links(plan.base_url, post_path, plan.language, [plan.language])
)
end) ++
Enum.flat_map(translatable_posts ++ do_not_translate_posts, fn post ->
if "page" in (post.categories || []) and to_string(post.slug) != "" do
page_path = relative_path_to_url_path(page_output_path(post.slug, nil))
languages = if truthy_flag?(Map.get(post, :do_not_translate)), do: [plan.language], else: all_languages
[
render_multi_language_sitemap_url(
url_for_path(plan.base_url, page_path),
unix_ms_to_iso8601(post.updated_at),
"weekly",
"0.7",
build_hreflang_links(plan.base_url, page_path, plan.language, languages)
)
]
else
[]
end
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_year, &elem(&1, 0), :desc), fn {year, _posts} ->
year_path = "/#{year}"
render_multi_language_sitemap_url(
url_for_path(plan.base_url, year_path),
latest_post_updated_at,
"monthly",
"0.5",
build_hreflang_links(plan.base_url, year_path, plan.language, all_languages)
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc), fn {year_month, _posts} ->
month_path = "/#{year_month}"
render_multi_language_sitemap_url(
url_for_path(plan.base_url, month_path),
latest_post_updated_at,
"monthly",
"0.5",
build_hreflang_links(plan.base_url, month_path, plan.language, all_languages)
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_year_month_day, &elem(&1, 0), :desc), fn {year_month_day, _posts} ->
day_path = "/#{year_month_day}"
render_multi_language_sitemap_url(
url_for_path(plan.base_url, day_path),
latest_post_updated_at,
"monthly",
"0.4",
build_hreflang_links(plan.base_url, day_path, plan.language, all_languages)
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_category, &elem(&1, 0)), fn {category, _posts} ->
category_path = "/category/#{archive_route_segment(category)}"
render_multi_language_sitemap_url(
url_for_path(plan.base_url, category_path),
latest_post_updated_at,
"weekly",
"0.6",
build_hreflang_links(plan.base_url, category_path, plan.language, all_languages)
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_tag, &elem(&1, 0)), fn {tag, _posts} ->
tag_path = "/tag/#{archive_route_segment(tag)}"
render_multi_language_sitemap_url(
url_for_path(plan.base_url, tag_path),
latest_post_updated_at,
"weekly",
"0.6",
build_hreflang_links(plan.base_url, tag_path, plan.language, all_languages)
)
end)
[
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">",
Enum.join(urls, "\n"),
"</urlset>",
""
]
|> Enum.join("\n")
end
defp latest_post_updated_at_iso([]), do: DateTime.utc_now() |> DateTime.to_iso8601()
defp latest_post_updated_at_iso([post | _rest]), do: unix_ms_to_iso8601(post.updated_at)
defp root_pagination_pages(total_items, max_posts_per_page) do
case page_count(total_items, max_posts_per_page) do
total_pages when total_pages > 1 -> Enum.to_list(2..total_pages)
_other -> []
end
end
defp unix_ms_to_iso8601(nil), do: DateTime.utc_now() |> DateTime.to_iso8601()
defp unix_ms_to_iso8601(value), do: value |> Persistence.from_unix_ms!() |> DateTime.to_iso8601()
defp url_for_path(nil, path), do: ensure_trailing_slash(path)
defp url_for_path(base_url, path) do
String.trim_trailing(base_url, "/") <> ensure_trailing_slash(path)
end
defp ensure_trailing_slash(path) do
normalized_path = normalize_url_path(path)
if normalized_path == "/", do: "/", else: normalized_path <> "/"
end
defp build_hreflang_links(base_url, url_path, main_language, languages) do
Enum.map(languages, fn language ->
prefixed_path =
if language == main_language do
url_path
else
normalize_url_path("/#{language}#{url_path}")
end
canonical_href = url_for_path(base_url, prefixed_path)
" <xhtml:link rel=\"alternate\" hreflang=\"#{xml_escape(language)}\" href=\"#{xml_escape(canonical_href)}\" />"
end) ++
[
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"#{xml_escape(url_for_path(base_url, url_path))}\" />"
]
end
defp render_multi_language_sitemap_url(loc, lastmod, changefreq, priority, hreflang_links) do
[
" <url>",
" <loc>#{xml_escape(loc)}</loc>",
" <lastmod>#{xml_escape(lastmod)}</lastmod>",
" <changefreq>#{changefreq}</changefreq>",
" <priority>#{priority}</priority>",
Enum.join(hreflang_links, "\n"),
" </url>"
]
|> Enum.join("\n")
end
defp sitemap_route_output?("404.html"), do: false
defp sitemap_route_output?("feed.xml"), do: false
defp sitemap_route_output?("atom.xml"), do: false
defp sitemap_route_output?("calendar.json"), do: false
defp sitemap_route_output?(relative_path), do: String.ends_with?(relative_path, ".html")
defp build_pagefind_outputs(plan, html_outputs) do
language_outputs =
plan.blog_languages
|> Enum.uniq()
|> Enum.flat_map(fn language ->
route_language = route_language(plan.language, language)
pages = pagefind_pages_for_language(html_outputs, route_language)
prefix = if route_language in [nil, ""], do: ["pagefind"], else: [route_language, "pagefind"]
[
{Path.join(prefix ++ ["index.json"]), Jason.encode!(%{"language" => language, "pages" => pages})},
{Path.join(prefix ++ ["pagefind-ui.js"]), pagefind_ui_js(language)},
{Path.join(prefix ++ ["pagefind-ui.css"]), pagefind_ui_css()}
]
end)
language_outputs
end
defp pagefind_pages_for_language(html_outputs, route_language) do
html_outputs
|> Enum.filter(fn {relative_path, _content} ->
String.ends_with?(relative_path, ".html") and pagefind_language_match?(relative_path, route_language)
end)
|> Enum.map(fn {relative_path, content} ->
%{
"url" => "/" <> relative_path,
"text" => pagefind_text(content)
}
end)
end
defp pagefind_language_match?(relative_path, nil), do: not String.starts_with?(relative_path, ["de/", "fr/", "it/", "es/"])
defp pagefind_language_match?(relative_path, ""), do: pagefind_language_match?(relative_path, nil)
defp pagefind_language_match?(relative_path, route_language), do: String.starts_with?(relative_path, route_language <> "/")
defp pagefind_text(content) do
content
|> String.replace(~r/<[^>]+>/, " ")
|> String.replace(~r/\s+/u, " ")
|> String.trim()
end
defp pagefind_ui_js(language) do
"window.bDSPagefind = { language: #{Jason.encode!(language)} };\n"
end
defp pagefind_ui_css do
".pagefind-ui{display:block;}\n"
end
defp render_post_page(title, body, slug, language) do
[
"<html>",
"<head><title>",
to_string(title),
"</title></head>",
"<body data-slug=\"",
to_string(slug),
"\" data-language=\"",
to_string(language),
"\"><article data-pagefind-body>",
body,
"</article></body>",
"</html>"
]
|> IO.iodata_to_binary()
end
defp render_archive_page(plan, title, posts, language, kind, pagination) do
fallback = fn ->
items =
posts
|> Enum.map(fn post -> ["<li>", post.title, "</li>"] end)
|> IO.iodata_to_binary()
[
"<html><body data-kind=\"",
kind,
"\" data-language=\"",
to_string(language),
"\"><h1>",
title,
"</h1><ul>",
items,
"</ul></body></html>"
]
|> IO.iodata_to_binary()
end
render_list_output(
plan,
language,
title,
Enum.map(posts, fn post ->
%{
id: post.id,
slug: post.slug,
title: post.title,
href: "#",
excerpt: post.excerpt,
content: nil,
language: post.language
}
end),
%{kind: kind, name: title},
pagination,
fallback
)
end
defp render_date_archive_page(plan, label, archive_context, posts, language, pagination) do
fallback = fn ->
items =
posts
|> Enum.map(fn post -> ["<li>", post.title, "</li>"] end)
|> IO.iodata_to_binary()
[
"<html><body data-kind=\"date\" data-language=\"",
to_string(language),
"\"><h1>",
label,
"</h1><ul>",
items,
"</ul></body></html>"
]
|> IO.iodata_to_binary()
end
render_list_output(
plan,
language,
label,
build_list_posts(plan.base_url, posts, route_language(plan.language, language)),
archive_context,
pagination,
fallback
)
end
defp load_body(_project_id, _file_path, inline_content) when is_binary(inline_content),
do: inline_content
defp load_body(project_id, file_path, _inline_content) do
case file_path do
nil ->
""
"" ->
""
value ->
project_path =
Path.expand(value, Projects.project_data_dir(Projects.get_project!(project_id)))
case File.read(project_path) do
{:ok, contents} -> parse_frontmatter_body(contents)
{:error, _reason} -> ""
end
end
end
defp parse_frontmatter_body(contents) do
case String.split(contents, "\n---\n", parts: 2) do
[_frontmatter, body] -> String.trim_trailing(body, "\n")
_parts -> contents
end
end
defp build_list_posts(base_url, posts, language_prefix) do
Enum.map(posts, fn post ->
%{
id: post.id,
slug: post.slug,
title: post.title,
href: url_for_output(base_url, post_output_path(post, language_prefix)),
excerpt: post.excerpt,
content: load_body(post.project_id, post.file_path, post.content)
}
end)
end
defp render_post_output(project_id, template_slug, assigns, fallback) do
case Rendering.render_post_page(project_id, template_slug, assigns) do
{:ok, rendered} -> rendered
{:error, _reason} -> fallback.()
end
end
defp render_list_output(
%{project_id: project_id, language: main_language},
language,
page_title,
posts,
archive_context,
pagination,
fallback
)
when is_binary(project_id) do
case Rendering.render_list_page(project_id, %{
language: language,
language_prefix: language_prefix(language, main_language),
page_title: page_title,
posts: posts,
archive_context: archive_context,
pagination: pagination
}) do
{:ok, rendered} -> rendered
{:error, _reason} -> fallback.()
end
end
defp render_not_found_output(%{project_id: project_id, language: main_language}, language)
when is_binary(project_id) do
case Rendering.render_not_found_page(project_id, %{
language: language,
language_prefix: language_prefix(language, main_language)
}) do
{:ok, rendered} -> rendered
{:error, _reason} -> render_not_found_page(language)
end
end
defp language_prefix(language, main_language) when language == main_language, do: ""
defp language_prefix(nil, _main_language), do: ""
defp language_prefix(language, _main_language), do: "/#{language}"
defp archive_href(language, segments, page_number) do
archive_path(language, segments, page_number)
|> String.trim_trailing("index.html")
|> then(&("/" <> String.trim_leading(&1, "/")))
end
defp url_for_output(nil, relative_path), do: "/" <> String.trim_leading(relative_path, "/")
defp url_for_output(base_url, relative_path) do
cleaned = relative_path |> String.trim_leading("/") |> String.trim_trailing("index.html")
suffix = if cleaned == "", do: "/", else: "/" <> cleaned
String.trim_trailing(base_url, "/") <> suffix
end
defp render_not_found_page(language) do
[
"<html><body data-language=\"",
to_string(language),
"\"><section data-template=\"not-found\"><h1>404</h1><p>Not Found</p></section></body></html>"
]
|> IO.iodata_to_binary()
end
defp xml_escape(value) do
value
|> to_string()
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&apos;")
end
defp upsert_generated_file_hash(project_id, relative_path, content_hash, now) do defp upsert_generated_file_hash(project_id, relative_path, content_hash, now) do
%GeneratedFileHash{} %GeneratedFileHash{}
|> GeneratedFileHash.changeset(%{ |> GeneratedFileHash.changeset(%{
@@ -2107,8 +1445,8 @@ defmodule BDS.Generation do
expected_path_set = expected_path_set =
params.sitemap_xml params.sitemap_xml
|> extract_sitemap_locs() |> extract_locs()
|> Enum.map(&sitemap_loc_to_project_path(&1, params.base_url)) |> Enum.map(&loc_to_project_path(&1, params.base_url))
|> Enum.reduce(MapSet.new(), &MapSet.put(&2, normalize_url_path(&1))) |> Enum.reduce(MapSet.new(), &MapSet.put(&2, normalize_url_path(&1)))
|> then(fn expected_paths -> |> then(fn expected_paths ->
Enum.reduce(Map.get(params, :additional_expected_paths, []), expected_paths, fn path, acc -> Enum.reduce(Map.get(params, :additional_expected_paths, []), expected_paths, fn path, acc ->
@@ -2190,34 +1528,6 @@ defmodule BDS.Generation do
} }
end end
defp extract_sitemap_locs(sitemap_xml) do
Regex.scan(~r/<loc>(.*?)<\/loc>/, sitemap_xml, capture: :all_but_first)
|> Enum.map(fn [value] -> String.trim(value) end)
|> Enum.reject(&(&1 == ""))
end
defp sitemap_loc_to_project_path(loc, nil), do: normalize_url_path(loc)
defp sitemap_loc_to_project_path(loc, base_url) do
with {:ok, loc_uri} <- URI.new(loc),
{:ok, base_uri} <- URI.new(base_url) do
loc_path = String.trim_trailing(loc_uri.path || "/", "/")
base_path = String.trim_trailing(base_uri.path || "", "/")
cond do
base_path != "" and String.starts_with?(loc_path, base_path) ->
loc_path
|> String.replace_prefix(base_path, "")
|> normalize_url_path()
true ->
normalize_url_path(loc_path)
end
else
_other -> normalize_url_path(loc)
end
end
defp collect_html_index_paths(index_paths, html_dir, on_progress, total_compare_steps) do defp collect_html_index_paths(index_paths, html_dir, on_progress, total_compare_steps) do
index_paths index_paths
|> Enum.with_index(1) |> Enum.with_index(1)
@@ -2243,56 +1553,6 @@ defmodule BDS.Generation do
end) end)
end end
defp report_validation_compare_progress(nil, _current, _total), do: :ok
defp report_validation_compare_progress(_callback, _current, total) when total <= 0, do: :ok
defp 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
end
defp normalize_url_path(nil), do: "/"
defp normalize_url_path(url_path) do
trimmed = String.trim(url_path || "")
cond do
trimmed in ["", "/"] ->
"/"
true ->
trimmed
|> String.split(["?", "#"])
|> List.first()
|> to_string()
|> String.trim("/")
|> case do
"" -> "/"
value -> "/" <> value
end
end
end
defp relative_path_to_url_path(relative_path) do
relative_path
|> String.trim_leading("/")
|> String.trim_trailing("index.html")
|> String.trim_trailing("/")
|> case do
"" -> "/"
value -> "/" <> value
end
end
defp url_path_to_relative_index_path("/"), do: "index.html"
defp url_path_to_relative_index_path(url_path) do
url_path
|> normalize_url_path()
|> String.trim_leading("/")
|> Path.join("index.html")
end
defp mtime_ms(%{mtime: mtime}) when is_integer(mtime) do defp mtime_ms(%{mtime: mtime}) when is_integer(mtime) do
mtime * 1000 mtime * 1000
@@ -2450,17 +1710,6 @@ defmodule BDS.Generation do
post.slug == route.slug and year == route.year and month == route.month and day == route.day post.slug == route.slug and year == route.year and month == route.month and day == route.day
end end
defp local_date_parts!(value) do
normalized = Persistence.normalize_unix_timestamp(value)
{{year, month, day}, _time} = :calendar.system_time_to_local_time(normalized, :millisecond)
{year, month, day}
end
defp local_date_iso8601!(value) do
{year, month, day} = local_date_parts!(value)
Date.new!(year, month, day) |> Date.to_iso8601()
end
defp route_key(year, month, day, slug) do defp route_key(year, month, day, slug) do
"#{year}/#{String.pad_leading(Integer.to_string(month), 2, "0")}/#{String.pad_leading(Integer.to_string(day), 2, "0")}/#{slug}" "#{year}/#{String.pad_leading(Integer.to_string(month), 2, "0")}/#{String.pad_leading(Integer.to_string(day), 2, "0")}/#{slug}"
end end

View File

@@ -0,0 +1,70 @@
defmodule BDS.Generation.Pagefind do
@moduledoc false
@typedoc "An (relative_path, content) HTML output tuple."
@type html_output :: {String.t(), String.t()}
@typedoc "A (relative_path, content) generated file tuple."
@type generated_file :: {String.t(), String.t()}
@doc """
Build the per-language Pagefind index outputs (`pagefind/index.json`,
`pagefind/pagefind-ui.js`, `pagefind/pagefind-ui.css`) for every blog
language declared on the plan.
"""
@spec build_outputs(map(), [html_output()]) :: [generated_file()]
def build_outputs(plan, html_outputs) do
plan.blog_languages
|> Enum.uniq()
|> Enum.flat_map(fn language ->
route_language = route_language(plan.language, language)
pages = pages_for_language(html_outputs, route_language)
prefix = if route_language in [nil, ""], do: ["pagefind"], else: [route_language, "pagefind"]
[
{Path.join(prefix ++ ["index.json"]), Jason.encode!(%{"language" => language, "pages" => pages})},
{Path.join(prefix ++ ["pagefind-ui.js"]), ui_js(language)},
{Path.join(prefix ++ ["pagefind-ui.css"]), ui_css()}
]
end)
end
defp pages_for_language(html_outputs, route_language) do
html_outputs
|> Enum.filter(fn {relative_path, _content} ->
String.ends_with?(relative_path, ".html") and language_match?(relative_path, route_language)
end)
|> Enum.map(fn {relative_path, content} ->
%{
"url" => "/" <> relative_path,
"text" => text(content)
}
end)
end
defp language_match?(relative_path, nil),
do: not String.starts_with?(relative_path, ["de/", "fr/", "it/", "es/"])
defp language_match?(relative_path, ""), do: language_match?(relative_path, nil)
defp language_match?(relative_path, route_language),
do: String.starts_with?(relative_path, route_language <> "/")
defp text(content) do
content
|> String.replace(~r/<[^>]+>/, " ")
|> String.replace(~r/\s+/u, " ")
|> String.trim()
end
defp ui_js(language) do
"window.bDSPagefind = { language: #{Jason.encode!(language)} };\n"
end
defp ui_css do
".pagefind-ui{display:block;}\n"
end
defp route_language(main_language, language) when main_language == language, do: nil
defp route_language(_main_language, language), do: language
end

262
lib/bds/generation/paths.ex Normal file
View File

@@ -0,0 +1,262 @@
defmodule BDS.Generation.Paths do
@moduledoc false
alias BDS.Persistence
@typedoc "A language identifier (e.g. `\"en\"`) or `nil`/`\"\"` for the main language."
@type language :: String.t() | nil
@doc "Output path for a published post (e.g. `2024/05/12/slug/index.html`)."
@spec post_output_path(map()) :: String.t()
def post_output_path(post), do: post_output_path(post, nil)
@spec post_output_path(map(), language()) :: String.t()
def post_output_path(post, language) when is_map(post) do
{year, month, day} = local_date_parts!(post.created_at)
year = Integer.to_string(year)
month = month |> Integer.to_string() |> String.pad_leading(2, "0")
day = day |> Integer.to_string() |> String.pad_leading(2, "0")
path_parts = [year, month, day, post.slug, "index.html"]
case language do
nil -> Path.join(path_parts)
"" -> Path.join(path_parts)
value -> Path.join([value | path_parts])
end
end
@spec paginated_archive_paths(language(), [String.t()], non_neg_integer(), pos_integer()) ::
[String.t()]
def paginated_archive_paths(route_language, segments, total_items, max_posts_per_page) do
total_pages = page_count(total_items, max_posts_per_page)
Enum.map(1..total_pages, fn page_number ->
archive_path(route_language, segments, page_number)
end)
end
@spec root_route_paths(language(), non_neg_integer(), pos_integer()) :: [String.t()]
def root_route_paths(route_language, total_items, max_posts_per_page) do
total_pages = page_count(total_items, max_posts_per_page)
Enum.map(1..total_pages, fn page_number ->
root_output_path(route_language, page_number)
end)
end
@spec root_output_path(language(), pos_integer()) :: String.t()
def root_output_path(nil, 1), do: "index.html"
def root_output_path("", 1), do: "index.html"
def root_output_path(route_language, 1), do: Path.join(route_language, "index.html")
def root_output_path(nil, page_number), do: Path.join(["page", Integer.to_string(page_number), "index.html"])
def root_output_path("", page_number), do: root_output_path(nil, page_number)
def root_output_path(route_language, page_number), do: Path.join([route_language, "page", Integer.to_string(page_number), "index.html"])
@spec page_output_path(String.t(), language()) :: String.t()
def page_output_path(slug, nil), do: Path.join([slug, "index.html"])
def page_output_path(slug, ""), do: page_output_path(slug, nil)
def page_output_path(slug, language), do: Path.join([language, slug, "index.html"])
@spec pagination_for_page(pos_integer(), pos_integer(), non_neg_integer(), pos_integer(), language(), [String.t()]) ::
map()
def pagination_for_page(page_number, total_pages, total_items, items_per_page, route_language, segments) do
%{
current_page: page_number,
total_pages: total_pages,
total_items: total_items,
items_per_page: items_per_page,
has_prev_page: page_number > 1,
prev_page_href: archive_or_root_href(route_language, segments, page_number - 1),
has_next_page: page_number < total_pages,
next_page_href: archive_or_root_href(route_language, segments, page_number + 1)
}
end
@spec archive_or_root_href(language(), [String.t()], integer()) :: String.t()
def archive_or_root_href(_route_language, _segments, page_number) when page_number < 1, do: ""
def archive_or_root_href(route_language, [], page_number), do: root_page_href(route_language, page_number)
def archive_or_root_href(route_language, segments, page_number), do: archive_href(route_language, segments, page_number)
@spec root_page_href(language(), integer()) :: String.t()
def root_page_href(route_language, page_number) when page_number <= 1 do
case route_language do
nil -> "/"
"" -> "/"
language -> "/#{language}/"
end
end
def root_page_href(route_language, page_number) do
base =
case route_language do
nil -> ""
"" -> ""
language -> "/#{language}"
end
"#{base}/page/#{page_number}/"
end
@spec archive_href(language(), [String.t()], pos_integer()) :: String.t()
def archive_href(language, segments, page_number) do
archive_path(language, segments, page_number)
|> String.trim_trailing("index.html")
|> then(&("/" <> String.trim_leading(&1, "/")))
end
@spec page_count(integer(), pos_integer()) :: pos_integer()
def page_count(total_items, _max_posts_per_page) when total_items <= 0, do: 1
def page_count(total_items, max_posts_per_page) do
page_size = max(max_posts_per_page, 1)
div(total_items + page_size - 1, page_size)
end
@spec paginate_posts([map()], pos_integer()) :: [[map()]]
def paginate_posts(posts, max_posts_per_page) do
case Enum.chunk_every(posts, max(max_posts_per_page, 1)) do
[] -> [[]]
chunks -> chunks
end
end
@spec root_pagination_pages(non_neg_integer(), pos_integer()) :: [pos_integer()]
def root_pagination_pages(total_items, max_posts_per_page) do
case page_count(total_items, max_posts_per_page) do
total_pages when total_pages > 1 -> Enum.to_list(2..total_pages)
_other -> []
end
end
@spec archive_path(language(), [String.t()], pos_integer()) :: String.t()
def archive_path(language, segments, 1), do: archive_path(language, segments)
def archive_path(language, segments, page_number) do
archive_path(language, segments ++ ["page", Integer.to_string(page_number)])
end
@spec archive_path(language(), [String.t()]) :: String.t()
def archive_path(nil, segments), do: Path.join(segments ++ ["index.html"])
def archive_path("", segments), do: Path.join(segments ++ ["index.html"])
def archive_path(language, segments) do
prefix = if language in [nil, ""], do: [], else: [language]
Path.join(prefix ++ segments ++ ["index.html"])
end
@spec archive_route_segment(any()) :: String.t()
def archive_route_segment(nil), do: ""
def archive_route_segment(value), do: value |> to_string() |> URI.encode(&URI.char_unreserved?/1)
@spec normalize_base_url(String.t() | nil) :: String.t() | nil
def normalize_base_url(nil), do: nil
def normalize_base_url(url), do: String.trim_trailing(url, "/")
@spec normalize_blog_languages(String.t() | nil, [String.t()] | nil) :: [String.t()]
def normalize_blog_languages(main_language, blog_languages) do
([main_language] ++ (blog_languages || []))
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq()
end
@spec route_language(language(), language()) :: language()
def route_language(main_language, language) when main_language == language, do: nil
def route_language(_main_language, language), do: language
@spec language_prefix(language(), language()) :: String.t()
def language_prefix(language, main_language) when language == main_language, do: ""
def language_prefix(nil, _main_language), do: ""
def language_prefix(language, _main_language), do: "/#{language}"
@spec url_for_path(String.t() | nil, String.t()) :: String.t()
def url_for_path(nil, path), do: ensure_trailing_slash(path)
def url_for_path(base_url, path) do
String.trim_trailing(base_url, "/") <> ensure_trailing_slash(path)
end
@spec url_for_output(String.t() | nil, String.t()) :: String.t()
def url_for_output(nil, relative_path), do: "/" <> String.trim_leading(relative_path, "/")
def url_for_output(base_url, relative_path) do
cleaned = relative_path |> String.trim_leading("/") |> String.trim_trailing("index.html")
suffix = if cleaned == "", do: "/", else: "/" <> cleaned
String.trim_trailing(base_url, "/") <> suffix
end
@spec ensure_trailing_slash(String.t()) :: String.t()
def ensure_trailing_slash(path) do
normalized_path = normalize_url_path(path)
if normalized_path == "/", do: "/", else: normalized_path <> "/"
end
@spec normalize_url_path(String.t() | nil) :: String.t()
def normalize_url_path(nil), do: "/"
def normalize_url_path(url_path) do
trimmed = String.trim(url_path || "")
cond do
trimmed in ["", "/"] ->
"/"
true ->
trimmed
|> String.split(["?", "#"])
|> List.first()
|> to_string()
|> String.trim("/")
|> case do
"" -> "/"
value -> "/" <> value
end
end
end
@spec relative_path_to_url_path(String.t()) :: String.t()
def relative_path_to_url_path(relative_path) do
relative_path
|> String.trim_leading("/")
|> String.trim_trailing("index.html")
|> String.trim_trailing("/")
|> case do
"" -> "/"
value -> "/" <> value
end
end
@spec url_path_to_relative_index_path(String.t()) :: String.t()
def url_path_to_relative_index_path("/"), do: "index.html"
def url_path_to_relative_index_path(url_path) do
url_path
|> normalize_url_path()
|> String.trim_leading("/")
|> Path.join("index.html")
end
@spec sitemap_route_output?(String.t()) :: boolean()
def sitemap_route_output?("404.html"), do: false
def sitemap_route_output?("feed.xml"), do: false
def sitemap_route_output?("atom.xml"), do: false
def sitemap_route_output?("calendar.json"), do: false
def sitemap_route_output?(relative_path), do: String.ends_with?(relative_path, ".html")
@spec truthy_flag?(term()) :: boolean()
def truthy_flag?(value), do: value not in [false, nil]
@doc "Returns the local-time `{year, month, day}` for a unix-ms-or-binary timestamp."
@spec local_date_parts!(term()) :: {integer(), integer(), integer()}
def local_date_parts!(value) do
normalized = Persistence.normalize_unix_timestamp(value)
{{year, month, day}, _time} = :calendar.system_time_to_local_time(normalized, :millisecond)
{year, month, day}
end
@spec local_date_iso8601!(term()) :: String.t()
def local_date_iso8601!(value) do
{year, month, day} = local_date_parts!(value)
Date.new!(year, month, day) |> Date.to_iso8601()
end
end

View File

@@ -0,0 +1,96 @@
defmodule BDS.Generation.Progress do
@moduledoc false
@typedoc "A 2-arity progress callback `(progress :: float(), message :: String.t()) -> any()`."
@type callback :: (float(), String.t() -> any()) | nil
@typedoc "A 3-arity stage callback `(stage :: atom(), current :: integer(), total :: integer()) -> any()`."
@type stage_callback :: (atom(), integer(), integer() -> any()) | nil
@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
@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
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}")
:ok
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
callback.(progress, message)
: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
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
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
end
@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
def report_snapshot_stage_progress(callback, stage, current, total) do
callback.(stage, current, total)
:ok
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
end
end

View File

@@ -0,0 +1,227 @@
defmodule BDS.Generation.Renderers do
@moduledoc false
alias BDS.Generation.Paths
alias BDS.Projects
alias BDS.Rendering
@doc "Render the home page (HTML) using the project's template engine."
@spec render_home(map(), String.t() | nil) :: String.t()
def render_home(plan, language) do
[
"<html>",
"<head><title>",
plan.project_name,
"</title></head>",
"<body data-language=\"",
to_string(language),
"\"><main><h1>",
plan.project_name,
"</h1></main></body>",
"</html>"
]
|> IO.iodata_to_binary()
end
@doc "Render a single post page using the post template (fallback to a tiny inline shell)."
@spec render_post_page(String.t(), iodata(), String.t(), String.t() | nil) :: String.t()
def render_post_page(title, body, slug, language) do
[
"<html>",
"<head><title>",
to_string(title),
"</title></head>",
"<body data-slug=\"",
to_string(slug),
"\" data-language=\"",
to_string(language),
"\"><article data-pagefind-body>",
body,
"</article></body>",
"</html>"
]
|> IO.iodata_to_binary()
end
@doc "Render an archive page (category, tag, year) with pagination."
@spec render_archive_page(map(), String.t(), [map()], String.t() | nil, String.t(), map()) :: String.t()
def render_archive_page(plan, title, posts, language, kind, pagination) do
fallback = fn ->
items =
posts
|> Enum.map(fn post -> ["<li>", post.title, "</li>"] end)
|> IO.iodata_to_binary()
[
"<html><body data-kind=\"",
kind,
"\" data-language=\"",
to_string(language),
"\"><h1>",
title,
"</h1><ul>",
items,
"</ul></body></html>"
]
|> IO.iodata_to_binary()
end
render_list_output(
plan,
language,
title,
Enum.map(posts, fn post ->
%{
id: post.id,
slug: post.slug,
title: post.title,
href: "#",
excerpt: post.excerpt,
content: nil,
language: post.language
}
end),
%{kind: kind, name: title},
pagination,
fallback
)
end
@doc "Render a date-archive page (year/month/day) with pagination."
@spec render_date_archive_page(map(), String.t(), map(), [map()], String.t() | nil, map()) ::
String.t()
def render_date_archive_page(plan, label, archive_context, posts, language, pagination) do
fallback = fn ->
items =
posts
|> Enum.map(fn post -> ["<li>", post.title, "</li>"] end)
|> IO.iodata_to_binary()
[
"<html><body data-kind=\"date\" data-language=\"",
to_string(language),
"\"><h1>",
label,
"</h1><ul>",
items,
"</ul></body></html>"
]
|> IO.iodata_to_binary()
end
render_list_output(
plan,
language,
label,
build_list_posts(plan.base_url, posts, Paths.route_language(plan.language, language)),
archive_context,
pagination,
fallback
)
end
@doc "Try the project's post template; on error, fall back to the inline `fallback` thunk."
@spec render_post_output(String.t(), String.t() | nil, map(), (-> String.t())) :: String.t()
def render_post_output(project_id, template_slug, assigns, fallback) do
case Rendering.render_post_page(project_id, template_slug, assigns) do
{:ok, rendered} -> rendered
{:error, _reason} -> fallback.()
end
end
@doc "Render a list/archive page through the project template, falling back to inline."
@spec render_list_output(map(), String.t() | nil, String.t(), [map()], map(), map(), (-> String.t())) ::
String.t()
def render_list_output(
%{project_id: project_id, language: main_language},
language,
page_title,
posts,
archive_context,
pagination,
fallback
)
when is_binary(project_id) do
case Rendering.render_list_page(project_id, %{
language: language,
language_prefix: Paths.language_prefix(language, main_language),
page_title: page_title,
posts: posts,
archive_context: archive_context,
pagination: pagination
}) do
{:ok, rendered} -> rendered
{:error, _reason} -> fallback.()
end
end
@doc "Render the project's 404 page via its template, falling back to a static page."
@spec render_not_found_output(map(), String.t() | nil) :: String.t()
def render_not_found_output(%{project_id: project_id, language: main_language}, language)
when is_binary(project_id) do
case Rendering.render_not_found_page(project_id, %{
language: language,
language_prefix: Paths.language_prefix(language, main_language)
}) do
{:ok, rendered} -> rendered
{:error, _reason} -> render_not_found_page(language)
end
end
@doc "Static fallback HTML for a 404 page."
@spec render_not_found_page(String.t() | nil) :: String.t()
def render_not_found_page(language) do
[
"<html><body data-language=\"",
to_string(language),
"\"><section data-template=\"not-found\"><h1>404</h1><p>Not Found</p></section></body></html>"
]
|> IO.iodata_to_binary()
end
@doc "Build the list-of-posts payload (with hrefs and bodies) for archive/list templates."
@spec build_list_posts(String.t() | nil, [map()], String.t() | nil) :: [map()]
def build_list_posts(base_url, posts, language_prefix) do
Enum.map(posts, fn post ->
%{
id: post.id,
slug: post.slug,
title: post.title,
href: Paths.url_for_output(base_url, Paths.post_output_path(post, language_prefix)),
excerpt: post.excerpt,
content: load_body(post.project_id, post.file_path, post.content)
}
end)
end
@doc "Load the post body from disk (or pass-through inline content) for list rendering."
@spec load_body(String.t() | nil, String.t() | nil, String.t() | nil) :: String.t()
def load_body(_project_id, _file_path, inline_content) when is_binary(inline_content),
do: inline_content
def load_body(project_id, file_path, _inline_content) do
case file_path do
nil ->
""
"" ->
""
value ->
project_path =
Path.expand(value, Projects.project_data_dir(Projects.get_project!(project_id)))
case File.read(project_path) do
{:ok, contents} -> parse_frontmatter_body(contents)
{:error, _reason} -> ""
end
end
end
defp parse_frontmatter_body(contents) do
case String.split(contents, "\n---\n", parts: 2) do
[_frontmatter, body] -> String.trim_trailing(body, "\n")
_parts -> contents
end
end
end

View File

@@ -0,0 +1,280 @@
defmodule BDS.Generation.Sitemap do
@moduledoc false
alias BDS.Generation.Paths
alias BDS.Persistence
@doc "Render a simple sitemap with a flat list of URLs."
@spec render([String.t()]) :: String.t()
def render(urls) do
entries = Enum.map_join(urls, "", fn url -> "<url><loc>#{xml_escape(url)}</loc></url>" end)
"<urlset>#{entries}</urlset>"
end
@doc "Render the multilingual sitemap with hreflang alternates for the project."
@spec render_multi_language(map(), [map()], [map()], [map()], map(), [String.t()]) :: String.t()
def render_multi_language(
plan,
translatable_posts,
do_not_translate_posts,
published_list_posts,
post_index,
additional_languages
) do
all_languages = [plan.language | additional_languages]
latest_post_updated_at = latest_post_updated_at_iso(published_list_posts)
urls =
[
url_entry(
Paths.url_for_path(plan.base_url, "/"),
latest_post_updated_at,
"daily",
"1.0",
build_hreflang_links(plan.base_url, "/", plan.language, all_languages)
)
] ++
Enum.map(Paths.root_pagination_pages(length(published_list_posts), plan.max_posts_per_page), fn page_number ->
page_path = "/page/#{page_number}"
url_entry(
Paths.url_for_path(plan.base_url, page_path),
latest_post_updated_at,
"daily",
"0.9",
build_hreflang_links(plan.base_url, page_path, plan.language, all_languages)
)
end) ++
Enum.map(translatable_posts, fn post ->
post_path = Paths.relative_path_to_url_path(Paths.post_output_path(post))
url_entry(
Paths.url_for_path(plan.base_url, post_path),
unix_ms_to_iso8601(post.updated_at),
"monthly",
"0.8",
build_hreflang_links(plan.base_url, post_path, plan.language, all_languages)
)
end) ++
Enum.map(do_not_translate_posts, fn post ->
post_path = Paths.relative_path_to_url_path(Paths.post_output_path(post))
url_entry(
Paths.url_for_path(plan.base_url, post_path),
unix_ms_to_iso8601(post.updated_at),
"monthly",
"0.8",
build_hreflang_links(plan.base_url, post_path, plan.language, [plan.language])
)
end) ++
Enum.flat_map(translatable_posts ++ do_not_translate_posts, fn post ->
if "page" in (post.categories || []) and to_string(post.slug) != "" do
page_path = Paths.relative_path_to_url_path(Paths.page_output_path(post.slug, nil))
languages =
if Paths.truthy_flag?(Map.get(post, :do_not_translate)),
do: [plan.language],
else: all_languages
[
url_entry(
Paths.url_for_path(plan.base_url, page_path),
unix_ms_to_iso8601(post.updated_at),
"weekly",
"0.7",
build_hreflang_links(plan.base_url, page_path, plan.language, languages)
)
]
else
[]
end
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_year, &elem(&1, 0), :desc), fn {year, _posts} ->
year_path = "/#{year}"
url_entry(
Paths.url_for_path(plan.base_url, year_path),
latest_post_updated_at,
"monthly",
"0.5",
build_hreflang_links(plan.base_url, year_path, plan.language, all_languages)
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc), fn {year_month, _posts} ->
month_path = "/#{year_month}"
url_entry(
Paths.url_for_path(plan.base_url, month_path),
latest_post_updated_at,
"monthly",
"0.5",
build_hreflang_links(plan.base_url, month_path, plan.language, all_languages)
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_year_month_day, &elem(&1, 0), :desc), fn {year_month_day, _posts} ->
day_path = "/#{year_month_day}"
url_entry(
Paths.url_for_path(plan.base_url, day_path),
latest_post_updated_at,
"monthly",
"0.4",
build_hreflang_links(plan.base_url, day_path, plan.language, all_languages)
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_category, &elem(&1, 0)), fn {category, _posts} ->
category_path = "/category/#{Paths.archive_route_segment(category)}"
url_entry(
Paths.url_for_path(plan.base_url, category_path),
latest_post_updated_at,
"weekly",
"0.6",
build_hreflang_links(plan.base_url, category_path, plan.language, all_languages)
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_tag, &elem(&1, 0)), fn {tag, _posts} ->
tag_path = "/tag/#{Paths.archive_route_segment(tag)}"
url_entry(
Paths.url_for_path(plan.base_url, tag_path),
latest_post_updated_at,
"weekly",
"0.6",
build_hreflang_links(plan.base_url, tag_path, plan.language, all_languages)
)
end)
[
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">",
Enum.join(urls, "\n"),
"</urlset>",
""
]
|> Enum.join("\n")
end
@doc "Render an RSS feed for the given language."
@spec render_feed(map(), String.t() | nil, [map()]) :: String.t()
def render_feed(plan, language, published_posts) do
items =
published_posts
|> Enum.filter(&(&1.language == language or language == plan.language))
|> Enum.map(fn post ->
"<item><title>#{xml_escape(post.title)}</title><link>#{Paths.url_for_output(plan.base_url, Paths.post_output_path(post))}</link></item>"
end)
|> Enum.join()
"<rss><channel><title>#{xml_escape(plan.project_name)} (#{xml_escape(language || "default")})</title>#{items}</channel></rss>"
end
@doc "Render an Atom feed for the given language."
@spec render_atom(map(), String.t() | nil, [map()]) :: String.t()
def render_atom(plan, language, published_posts) do
entries =
published_posts
|> Enum.filter(&(&1.language == language or language == plan.language))
|> Enum.map(fn post ->
"<entry><title>#{xml_escape(post.title)}</title><id>#{Paths.url_for_output(plan.base_url, Paths.post_output_path(post))}</id></entry>"
end)
|> Enum.join()
"<feed><title>#{xml_escape(plan.project_name)} (#{xml_escape(language || "default")})</title>#{entries}</feed>"
end
@doc "Render a JSON calendar of all published posts."
@spec render_calendar([map()]) :: String.t()
def render_calendar(published_posts) do
published_posts
|> Enum.map(fn post ->
%{date: Paths.local_date_iso8601!(post.created_at), slug: post.slug, title: post.title}
end)
|> Jason.encode!()
end
@doc "Extract the `<loc>` values from a sitemap XML document."
@spec extract_locs(String.t()) :: [String.t()]
def extract_locs(sitemap_xml) do
Regex.scan(~r/<loc>(.*?)<\/loc>/, sitemap_xml, capture: :all_but_first)
|> Enum.map(fn [value] -> String.trim(value) end)
|> Enum.reject(&(&1 == ""))
end
@doc "Translate a sitemap `<loc>` URL to a normalized project-relative URL path."
@spec loc_to_project_path(String.t(), String.t() | nil) :: String.t()
def loc_to_project_path(loc, nil), do: Paths.normalize_url_path(loc)
def loc_to_project_path(loc, base_url) do
with {:ok, loc_uri} <- URI.new(loc),
{:ok, base_uri} <- URI.new(base_url) do
loc_path = String.trim_trailing(loc_uri.path || "/", "/")
base_path = String.trim_trailing(base_uri.path || "", "/")
cond do
base_path != "" and String.starts_with?(loc_path, base_path) ->
loc_path
|> String.replace_prefix(base_path, "")
|> Paths.normalize_url_path()
true ->
Paths.normalize_url_path(loc_path)
end
else
_other -> Paths.normalize_url_path(loc)
end
end
@doc "Escape a string for inclusion in XML."
@spec xml_escape(term()) :: String.t()
def xml_escape(value) do
value
|> to_string()
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&apos;")
end
@doc "ISO-8601 string of the most recently updated post (or now)."
@spec latest_post_updated_at_iso([map()]) :: String.t()
def latest_post_updated_at_iso([]), do: DateTime.utc_now() |> DateTime.to_iso8601()
def latest_post_updated_at_iso([post | _rest]), do: unix_ms_to_iso8601(post.updated_at)
@doc "Convert a unix-ms (or nil) timestamp to ISO-8601."
@spec unix_ms_to_iso8601(integer() | nil) :: String.t()
def unix_ms_to_iso8601(nil), do: DateTime.utc_now() |> DateTime.to_iso8601()
def unix_ms_to_iso8601(value), do: value |> Persistence.from_unix_ms!() |> DateTime.to_iso8601()
defp build_hreflang_links(base_url, url_path, main_language, languages) do
Enum.map(languages, fn language ->
prefixed_path =
if language == main_language do
url_path
else
Paths.normalize_url_path("/#{language}#{url_path}")
end
canonical_href = Paths.url_for_path(base_url, prefixed_path)
" <xhtml:link rel=\"alternate\" hreflang=\"#{xml_escape(language)}\" href=\"#{xml_escape(canonical_href)}\" />"
end) ++
[
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"#{xml_escape(Paths.url_for_path(base_url, url_path))}\" />"
]
end
defp url_entry(loc, lastmod, changefreq, priority, hreflang_links) do
[
" <url>",
" <loc>#{xml_escape(loc)}</loc>",
" <lastmod>#{xml_escape(lastmod)}</lastmod>",
" <changefreq>#{changefreq}</changefreq>",
" <priority>#{priority}</priority>",
Enum.join(hreflang_links, "\n"),
" </url>"
]
|> Enum.join("\n")
end
end

479
lib/bds/import_analysis.ex Normal file
View File

@@ -0,0 +1,479 @@
defmodule BDS.ImportAnalysis do
@moduledoc false
import Ecto.Query
alias BDS.Media.Media
alias BDS.Posts.Post
alias BDS.Repo
alias BDS.Tags.Tag
alias BDS.WxrParser
@shortcode_regex ~r/(?<!\[)\[(\w+)([^\]]*?)(?:\s*\/)?\](?!\])/u
@param_regex ~r/(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s\]"']+))/u
def analyze_wxr(project_id, wxr_file_path), do: analyze_wxr(project_id, wxr_file_path, nil, [])
def analyze_wxr(project_id, wxr_file_path, uploads_folder_path)
when is_binary(project_id) and is_binary(wxr_file_path) do
analyze_wxr(project_id, wxr_file_path, uploads_folder_path, [])
end
def analyze_wxr(project_id, wxr_file_path, uploads_folder_path, opts)
when is_binary(project_id) and is_binary(wxr_file_path) and is_list(opts) do
on_progress = Keyword.get(opts, :on_progress, fn _step, _detail -> :ok end)
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)}}
end
defp build_report(project_id, wxr_data, wxr_file_path, uploads_folder_path, on_progress) do
notify_progress(on_progress, "Loading existing posts...")
existing_posts = Repo.all(from post in Post, where: post.project_id == ^project_id)
notify_progress(on_progress, "Loading existing media...", "#{length(existing_posts)} posts in project")
existing_media = Repo.all(from media in Media, where: media.project_id == ^project_id)
notify_progress(on_progress, "Loading existing tags...", "#{length(existing_media)} media in project")
existing_tag_names = Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: tag.name)
existing_tag_set = existing_tag_names |> Enum.map(&String.downcase/1) |> MapSet.new()
posts_by_slug = Map.new(existing_posts, &{&1.slug, &1})
posts_by_checksum =
existing_posts
|> Enum.reject(&is_nil(&1.checksum))
|> Map.new(&{&1.checksum, &1})
media_by_name = Map.new(existing_media, &{String.downcase(&1.original_name), &1})
media_by_checksum =
existing_media
|> Enum.reject(&is_nil(&1.checksum))
|> Map.new(&{&1.checksum, &1})
notify_progress(on_progress, "Analyzing posts...", "#{length(wxr_data.posts)} posts to analyze")
analyzed_posts = Enum.map(wxr_data.posts, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "post"))
notify_progress(on_progress, "Analyzing pages...", "#{length(wxr_data.pages)} pages to analyze")
analyzed_pages = Enum.map(wxr_data.pages, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "page"))
notify_progress(on_progress, "Analyzing media files...", "#{length(wxr_data.media)} media files to analyze")
analyzed_media =
Enum.map(wxr_data.media, &analyze_media_item(&1, uploads_folder_path, media_by_name, media_by_checksum))
notify_progress(on_progress, "Processing categories and tags...")
category_items = Enum.map(wxr_data.categories, &analyze_taxonomy_item(&1, existing_tag_set))
tag_items = Enum.map(wxr_data.tags, &analyze_taxonomy_item(&1, existing_tag_set))
notify_progress(on_progress, "Discovering macros...")
macro_summary = analyze_macros(wxr_data.posts ++ wxr_data.pages)
posts_only = Enum.filter(analyzed_posts, &(&1.post_type == "post"))
other_posts = Enum.reject(analyzed_posts, &(&1.post_type == "post"))
%{
source_file: wxr_file_path,
site_info: %{
title: wxr_data.site.title,
url: wxr_data.site.link,
language: wxr_data.site.language,
source_file: wxr_file_path
},
post_stats: summarize_post_items(posts_only),
other_stats: summarize_other_items(other_posts),
page_stats: summarize_post_items(analyzed_pages),
media_stats: summarize_media_items(analyzed_media),
category_stats: summarize_taxonomy_items(category_items),
tag_stats: summarize_taxonomy_items(tag_items),
date_distribution: date_distribution(analyzed_posts, analyzed_pages, analyzed_media),
conflicts: conflicts(analyzed_posts, analyzed_pages, analyzed_media),
macros: macro_summary,
items: %{
posts: Enum.map(analyzed_posts, &summary_item/1),
pages: Enum.map(analyzed_pages, &summary_item/1),
media: Enum.map(analyzed_media, &summary_item/1),
categories: category_items,
tags: tag_items
},
details: %{
posts: analyzed_posts,
pages: analyzed_pages,
media: analyzed_media
}
}
end
defp analyze_post_item(wxr_post, posts_by_slug, posts_by_checksum, item_type) do
content_markdown = html_to_markdown(wxr_post.content || "")
content_checksum = sha256(content_markdown)
existing_by_slug = Map.get(posts_by_slug, wxr_post.slug)
existing_by_checksum = Map.get(posts_by_checksum, content_checksum)
{status, existing} =
cond do
existing_by_slug && existing_by_slug.checksum == content_checksum && not is_nil(existing_by_slug.checksum) -> {"update", existing_by_slug}
existing_by_slug -> {"conflict", existing_by_slug}
existing_by_checksum -> {"content-duplicate", existing_by_checksum}
true -> {"new", nil}
end
%{
item_type: item_type,
post_type: wxr_post.post_type || item_type,
wp_id: wxr_post.wp_id,
title: wxr_post.title,
slug: wxr_post.slug,
status: status,
resolution: if(status == "conflict", do: "ignore", else: nil),
existing_id: existing && existing.id,
existing_title: existing && existing.title,
author: blank_to_nil(wxr_post.creator),
excerpt: blank_to_nil(wxr_post.excerpt),
categories: wxr_post.categories,
tags: wxr_post.tags,
wp_status: blank_to_nil(wxr_post.status),
content_markdown: content_markdown,
content_checksum: content_checksum,
content_preview: String.slice(content_markdown, 0, 200),
created_at: wxr_post.post_date || wxr_post.pub_date,
updated_at: wxr_post.post_modified || wxr_post.post_date || wxr_post.pub_date,
published_at: wxr_post.pub_date
}
end
defp analyze_media_item(wxr_media, uploads_folder_path, media_by_name, media_by_checksum) do
source_file =
case uploads_folder_path do
nil -> nil
"" -> nil
path -> Path.join(path, wxr_media.relative_path)
end
{status, checksum, existing} =
cond do
is_nil(source_file) or not File.exists?(source_file) ->
{"missing", nil, nil}
true ->
binary = File.read!(source_file)
file_checksum = md5(binary)
existing_by_name = Map.get(media_by_name, String.downcase(wxr_media.filename))
existing_by_checksum = Map.get(media_by_checksum, file_checksum)
cond do
existing_by_name && existing_by_name.checksum == file_checksum && not is_nil(existing_by_name.checksum) -> {"update", file_checksum, existing_by_name}
existing_by_name -> {"conflict", file_checksum, existing_by_name}
existing_by_checksum -> {"content-duplicate", file_checksum, existing_by_checksum}
true -> {"new", file_checksum, nil}
end
end
%{
item_type: "media",
wp_id: wxr_media.wp_id,
title: wxr_media.title,
filename: wxr_media.filename,
relative_path: wxr_media.relative_path,
url: wxr_media.url,
status: status,
resolution: if(status == "conflict", do: "ignore", else: nil),
existing_id: existing && existing.id,
existing_title: existing && existing.title,
mime_type: wxr_media.mime_type,
description: blank_to_nil(wxr_media.description),
parent_wp_id: wxr_media.parent_id,
source_file: source_file,
checksum: checksum,
created_at: wxr_media.pub_date
}
end
defp analyze_taxonomy_item(item, existing_tag_set) do
exists_in_project = MapSet.member?(existing_tag_set, String.downcase(item.name))
%{
name: item.name,
slug: item.slug,
exists_in_project: exists_in_project,
mapped_to: nil
}
end
defp summary_item(%{item_type: "media"} = item) do
base = %{
item_type: item.item_type,
title: item.title,
filename: item.filename,
relative_path: item.relative_path,
status: item.status
}
maybe_put(base, :resolution, item.resolution)
end
defp summary_item(item) do
base = %{
item_type: item.item_type,
post_type: Map.get(item, :post_type, item.item_type),
title: item.title,
slug: item.slug,
status: item.status
}
maybe_put(base, :resolution, item.resolution)
end
defp summarize_post_items(items) do
%{
new_count: count_status(items, "new"),
update_count: count_status(items, "update"),
conflict_count: count_status(items, "conflict"),
duplicate_count: count_status(items, "content-duplicate")
}
end
defp summarize_other_items(items) do
%{
new_count: count_status(items, "new"),
update_count: count_status(items, "update"),
conflict_count: count_status(items, "conflict"),
duplicate_count: count_status(items, "content-duplicate"),
types: items |> Enum.map(&Map.get(&1, :post_type)) |> Enum.reject(&is_nil/1) |> Enum.uniq()
}
end
defp summarize_media_items(items) do
%{
new_count: count_status(items, "new"),
update_count: count_status(items, "update"),
conflict_count: count_status(items, "conflict"),
duplicate_count: count_status(items, "content-duplicate"),
missing_count: count_status(items, "missing")
}
end
defp summarize_taxonomy_items(items) do
%{
existing_count: Enum.count(items, & &1.exists_in_project),
mapped_count: Enum.count(items, &(not &1.exists_in_project and not is_nil(&1.mapped_to))),
new_count: Enum.count(items, &(not &1.exists_in_project and is_nil(&1.mapped_to)))
}
end
defp date_distribution(posts, pages, media) do
combined_posts = posts ++ pages
post_counts = Enum.reduce(combined_posts, %{}, &increment_year(&1.created_at || &1.published_at, &2))
media_counts = Enum.reduce(media, %{}, &increment_year(&1.created_at, &2))
post_counts
|> Map.keys()
|> Enum.concat(Map.keys(media_counts))
|> Enum.uniq()
|> Enum.sort()
|> Enum.map(fn year ->
%{
year: year,
post_count: Map.get(post_counts, year, 0),
media_count: Map.get(media_counts, year, 0)
}
end)
end
defp conflicts(posts, pages, media) do
(posts ++ pages ++ media)
|> Enum.filter(&(&1.status == "conflict"))
|> Enum.map(fn item ->
%{
item_type: item.item_type,
item_name: Map.get(item, :slug) || Map.get(item, :filename),
resolution: item.resolution || "ignore",
source_title: item.title,
existing_title: item.existing_title
}
end)
end
defp analyze_macros(items) do
macro_map =
Enum.reduce(items, %{}, fn item, acc ->
slug = Map.get(item, :slug)
Regex.scan(@shortcode_regex, item.content || "")
|> Enum.reduce(acc, fn [_match, name, raw_params], inner_acc ->
name = String.downcase(name)
params = parse_macro_params(raw_params)
params_key = serialize_params(params)
existing =
Map.get(inner_acc, name, %{
name: name,
total_count: 0,
usages: %{},
post_slugs: MapSet.new()
})
usage =
existing.usages
|> Map.get(params_key, %{params: params, count: 0})
|> Map.update(:count, 1, &(&1 + 1))
updated = %{
existing
| total_count: existing.total_count + 1,
usages: Map.put(existing.usages, params_key, usage),
post_slugs:
if(is_binary(slug), do: MapSet.put(existing.post_slugs, slug), else: existing.post_slugs)
}
Map.put(inner_acc, name, updated)
end)
end)
discovered =
macro_map
|> Map.values()
|> Enum.map(fn macro ->
%{
name: macro.name,
mapped: false,
total_count: macro.total_count,
usages:
macro.usages
|> Map.values()
|> Enum.map(fn usage ->
%{
params: usage.params,
count: usage.count,
validation_status: "unknown"
}
end),
post_slugs: MapSet.to_list(macro.post_slugs) |> Enum.sort()
}
end)
|> Enum.sort_by(& &1.name)
%{
total: length(discovered),
mapped_count: Enum.count(discovered, & &1.mapped),
unmapped_count: Enum.count(discovered, &(not &1.mapped)),
discovered: discovered
}
end
defp parse_macro_params(raw_params) do
Regex.scan(@param_regex, raw_params)
|> Enum.map(fn captures ->
key = Enum.at(captures, 1)
value = Enum.at(captures, 2) || Enum.at(captures, 3) || Enum.at(captures, 4) || ""
{key, value}
end)
|> Map.new()
end
defp serialize_params(params) when params == %{}, do: ""
defp serialize_params(params) do
params
|> Enum.sort_by(fn {k, _v} -> k end)
|> Enum.map(fn {k, v} -> "#{k}=#{v}" end)
|> Enum.join("|")
end
defp increment_year(nil, acc), do: acc
defp increment_year(value, acc) do
case year_from(value) do
nil -> acc
year -> Map.update(acc, year, 1, &(&1 + 1))
end
end
defp year_from(value) when is_integer(value) do
cond do
value > 100_000_000_000 -> value |> DateTime.from_unix!(:millisecond) |> DateTime.shift_zone!("Etc/UTC") |> Map.get(:year)
value > 1_000_000_000 -> value |> DateTime.from_unix!(:second) |> Map.get(:year)
true -> value
end
rescue
_error -> nil
end
defp year_from(value) when is_binary(value) do
normalized = String.replace(value, " ", "T")
case NaiveDateTime.from_iso8601(normalized) do
{:ok, naive} -> naive.year
_other ->
case DateTime.from_iso8601(value) do
{:ok, datetime, _offset} -> datetime.year
_ ->
case Regex.run(~r/(\d{4})/, value) do
[_, year] -> String.to_integer(year)
_other -> nil
end
end
end
end
defp year_from(_value), do: nil
defp count_status(items, status), do: Enum.count(items, &(&1.status == status))
defp notify_progress(callback, step, detail \\ nil) when is_function(callback, 2) do
try do
callback.(step, detail)
rescue
_error -> :ok
end
:ok
end
defp sha256(value) do
:sha256
|> :crypto.hash(value)
|> Base.encode16(case: :lower)
end
defp md5(binary) do
:md5
|> :crypto.hash(binary)
|> Base.encode16(case: :lower)
end
defp html_to_markdown(content) do
content
|> to_string()
|> String.replace(~r/<br\s*\/?>/i, "\n")
|> String.replace(~r|</p>|i, "\n\n")
|> String.replace(~r|<p[^>]*>|i, "")
|> String.replace(~r|<strong>(.*?)</strong>|is, "**\\1**")
|> String.replace(~r|<b>(.*?)</b>|is, "**\\1**")
|> String.replace(~r|<em>(.*?)</em>|is, "*\\1*")
|> String.replace(~r|<i>(.*?)</i>|is, "*\\1*")
|> String.replace(~r|<code>(.*?)</code>|is, "`\\1`")
|> String.replace(~r|<[^>]+>|u, "")
|> HtmlEntities.decode()
|> transform_shortcodes()
|> String.replace(~r/[ \t]+\n/u, "\n")
|> String.replace(~r/\n{3,}/u, "\n\n")
|> String.trim()
end
defp transform_shortcodes(content) do
Regex.replace(@shortcode_regex, content, fn _match, name, raw_params ->
inner = String.trim("#{name}#{raw_params}")
"[[#{inner}]]"
end)
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp blank_to_nil(nil), do: nil
defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value
end

View File

@@ -17,13 +17,60 @@ defmodule BDS.ImportDefinitions do
name: attr(attrs, :name) || "", name: attr(attrs, :name) || "",
wxr_file_path: attr(attrs, :wxr_file_path), wxr_file_path: attr(attrs, :wxr_file_path),
uploads_folder_path: attr(attrs, :uploads_folder_path), uploads_folder_path: attr(attrs, :uploads_folder_path),
last_analysis_result: attr(attrs, :last_analysis_result), last_analysis_result: normalize_analysis_result(attr(attrs, :last_analysis_result)),
created_at: now, created_at: now,
updated_at: now updated_at: now
}) })
|> Repo.insert() |> Repo.insert()
end end
def get_definition(definition_id) when is_binary(definition_id) do
Repo.get(ImportDefinition, definition_id)
end
def update_definition(definition_id, attrs) when is_binary(definition_id) and is_map(attrs) do
case Repo.get(ImportDefinition, definition_id) do
nil ->
{:error, :not_found}
%ImportDefinition{} = definition ->
updates =
%{}
|> maybe_put(:name, attr(attrs, :name))
|> maybe_put(:wxr_file_path, attr(attrs, :wxr_file_path))
|> maybe_put(:uploads_folder_path, attr(attrs, :uploads_folder_path))
|> maybe_put(:last_analysis_result, normalize_analysis_result(attr(attrs, :last_analysis_result)))
|> Map.put(:updated_at, Persistence.now_ms())
definition
|> ImportDefinition.changeset(updates)
|> Repo.update()
end
end
def delete_definition(definition_id) when is_binary(definition_id) do
case Repo.get(ImportDefinition, definition_id) do
nil -> {:error, :not_found}
%ImportDefinition{} = definition ->
Repo.delete(definition)
|> case do
{:ok, _deleted} -> {:ok, :deleted}
error -> error
end
end
end
def decode_analysis_result(%ImportDefinition{last_analysis_result: result}), do: decode_analysis_result(result)
def decode_analysis_result(result) when is_binary(result) do
case Jason.decode(result) do
{:ok, value} -> atomize_keys(value)
{:error, _reason} -> nil
end
end
def decode_analysis_result(_result), do: nil
def list_definitions(project_id) do def list_definitions(project_id) do
Repo.all( Repo.all(
from definition in ImportDefinition, from definition in ImportDefinition,
@@ -34,4 +81,23 @@ defmodule BDS.ImportDefinitions do
end end
defp attr(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) defp attr(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key))
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp normalize_analysis_result(nil), do: nil
defp normalize_analysis_result(value) when is_binary(value), do: value
defp normalize_analysis_result(value), do: Jason.encode!(value)
defp atomize_keys(value) when is_map(value) do
value
|> Enum.map(fn {key, nested_value} ->
normalized_key = if(is_binary(key), do: String.to_atom(key), else: key)
{normalized_key, atomize_keys(nested_value)}
end)
|> Map.new()
end
defp atomize_keys(value) when is_list(value), do: Enum.map(value, &atomize_keys/1)
defp atomize_keys(value), do: value
end end

489
lib/bds/import_execution.ex Normal file
View File

@@ -0,0 +1,489 @@
defmodule BDS.ImportExecution do
@moduledoc false
alias BDS.Media
alias BDS.Metadata
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.Repo
alias BDS.Tags
def execute_import(project_id, report, opts \\ []) when is_binary(project_id) and is_map(report) do
normalized_report = normalize_report(report)
default_author = Keyword.get(opts, :default_author) || project_default_author(project_id)
uploads_folder_path = Keyword.get(opts, :uploads_folder_path)
on_progress = Keyword.get(opts, :on_progress, fn _phase, _current, _total, _detail -> :ok end)
category_items = List.wrap(get_in(normalized_report, [:items, :categories]))
tag_items = List.wrap(get_in(normalized_report, [:items, :tags]))
category_mapping = build_taxonomy_mapping(category_items)
tag_mapping = build_taxonomy_mapping(tag_items)
post_items =
normalized_report
|> import_items(:posts)
|> Enum.filter(&(Map.get(&1, :post_type, "post") == "post"))
page_items = import_items(normalized_report, :pages)
media_items = import_items(normalized_report, :media)
taxonomy_total = length(category_items) + length(tag_items)
result = %{
success: true,
tags: %{created: 0, skipped: 0},
posts: %{imported: 0, skipped: 0, errors: 0},
media: %{imported: 0, skipped: 0, errors: 0},
pages: %{imported: 0, skipped: 0, errors: 0},
wp_id_to_post_id: %{},
errors: []
}
started_at = System.monotonic_time(:millisecond)
notify_progress(on_progress, "tags", 0, taxonomy_total, "creating_tags", started_at)
result = execute_taxonomies(category_items, tag_items, project_id, result, on_progress, started_at)
notify_progress(on_progress, "posts", 0, length(post_items), "importing_posts", started_at)
result = execute_posts(post_items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, :posts, started_at)
notify_progress(on_progress, "media", 0, length(media_items), "importing_media", started_at)
result = execute_media(media_items, project_id, default_author, result, on_progress, uploads_folder_path, started_at)
notify_progress(on_progress, "pages", 0, length(page_items), "importing_pages", started_at)
result = execute_posts(page_items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, :pages, started_at)
notify_progress(on_progress, "complete", 1, 1, "import_complete", started_at)
{:ok, result}
rescue
error -> {:error, %{message: Exception.message(error)}}
end
defp execute_taxonomies(category_items, tag_items, project_id, result, on_progress, started_at) do
items = category_items ++ tag_items
total = length(items)
items
|> Enum.with_index(1)
|> Enum.reduce(result, fn {item, index}, acc ->
cond do
Map.get(item, :exists_in_project) || not is_nil(Map.get(item, :mapped_to)) ->
notify_progress(on_progress, "tags", index, total, "skipped_tag:#{item.name}", started_at)
put_in(acc, [:tags, :skipped], acc.tags.skipped + 1)
true ->
case Tags.create_tag(%{project_id: project_id, name: item.name}) do
{:ok, _tag} ->
notify_progress(on_progress, "tags", index, total, "created_tag:#{item.name}", started_at)
put_in(acc, [:tags, :created], acc.tags.created + 1)
{:error, _reason} ->
notify_progress(on_progress, "tags", index, total, "skipped_tag:#{item.name}", started_at)
put_in(acc, [:tags, :skipped], acc.tags.skipped + 1)
end
end
end)
end
defp execute_posts(items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, bucket, started_at) do
total = length(items)
phase = Atom.to_string(bucket)
Enum.with_index(items, 1)
|> Enum.reduce(result, fn {item, index}, acc ->
notify_progress(on_progress, phase, index, total, "processing:#{item.title}", started_at)
execute_post_item(project_id, maybe_apply_page_category(item, bucket), acc, bucket, default_author, tag_mapping, category_mapping)
end)
end
defp execute_media(items, project_id, default_author, result, on_progress, uploads_folder_path, started_at) do
total = length(items)
items
|> Enum.with_index(1)
|> Enum.reduce(result, fn {item, index}, acc ->
notify_progress(on_progress, "media", index, total, "processing:#{item.filename}", started_at)
cond do
item.status == "missing" ->
put_in(acc, [:media, :skipped], acc.media.skipped + 1)
item.status in ["update", "content-duplicate", "duplicate"] ->
put_in(acc, [:media, :skipped], acc.media.skipped + 1)
item.status == "conflict" and resolve_conflict(item) == "ignore" ->
put_in(acc, [:media, :skipped], acc.media.skipped + 1)
true ->
case import_media_item(project_id, item, default_author, uploads_folder_path, acc) do
{:ok, _media} -> put_in(acc, [:media, :imported], acc.media.imported + 1)
{:error, reason} ->
acc
|> put_in([:media, :errors], acc.media.errors + 1)
|> Map.update!(:errors, &(&1 ++ [inspect(reason)]))
|> Map.put(:success, false)
end
end
end)
end
defp execute_post_item(project_id, item, result, bucket, default_author, tag_mapping, category_mapping) do
cond do
item.status in ["update", "content-duplicate", "duplicate"] ->
put_in(result, [bucket, :skipped], get_in(result, [bucket, :skipped]) + 1)
item.status == "conflict" and resolve_conflict(item) == "ignore" ->
put_in(result, [bucket, :skipped], get_in(result, [bucket, :skipped]) + 1)
item.status == "conflict" and resolve_conflict(item) == "overwrite" ->
case overwrite_post_item(item, default_author, tag_mapping, category_mapping) do
{:ok, post} ->
result
|> put_in([bucket, :imported], get_in(result, [bucket, :imported]) + 1)
|> track_wp_id(item, post)
{:error, reason} ->
result
|> put_in([bucket, :errors], get_in(result, [bucket, :errors]) + 1)
|> Map.update!(:errors, &(&1 ++ [inspect(reason)]))
|> Map.put(:success, false)
end
true ->
case create_post_item(project_id, item, default_author, tag_mapping, category_mapping) do
{:ok, post} ->
result
|> put_in([bucket, :imported], get_in(result, [bucket, :imported]) + 1)
|> track_wp_id(item, post)
{:error, reason} ->
result
|> put_in([bucket, :errors], get_in(result, [bucket, :errors]) + 1)
|> Map.update!(:errors, &(&1 ++ [inspect(reason)]))
|> Map.put(:success, false)
end
end
end
defp create_post_item(project_id, item, default_author, tag_mapping, category_mapping) do
attrs = post_create_attrs(project_id, item, default_author, tag_mapping, category_mapping)
with {:ok, post} <- Posts.create_post(attrs),
:ok <- prepare_created_post(post.id, item, tag_mapping, category_mapping),
{:ok, published_post} <- maybe_publish(post.id, item) do
{:ok, published_post}
end
end
defp overwrite_post_item(item, default_author, tag_mapping, category_mapping) do
case Repo.get(Post, item.existing_id) do
nil -> {:error, :not_found}
%Post{} = post ->
Posts.update_post(post.id, %{
title: item.title,
excerpt: item.excerpt,
content: item.content_markdown,
author: item.author || default_author,
tags: resolve_taxonomy(item.tags, tag_mapping),
categories: resolve_taxonomy(item.categories, category_mapping),
checksum: item.content_checksum
})
end
end
defp import_media_item(project_id, item, default_author, uploads_folder_path, result) do
source_path = item.source_file || uploads_source_path(item.relative_path, uploads_folder_path)
checksum = if(source_path != nil and File.exists?(source_path), do: md5(File.read!(source_path)), else: nil)
linked_post_ids = parent_post_ids(item, result)
if source_path && File.exists?(source_path) do
case {item.status, resolve_conflict(item)} do
{"conflict", "overwrite"} when item.existing_id != nil ->
with {:ok, _replaced} <- Media.replace_media_file(item.existing_id, source_path),
{:ok, _updated_media} <-
Media.update_media(item.existing_id, %{
title: item.title,
alt: item.description,
author: default_author
}) do
link_media(linked_post_ids, item.existing_id)
{:ok, Repo.get!(Media.Media, item.existing_id)}
end
_ ->
attrs = %{
project_id: project_id,
source_path: source_path,
title: item.title,
alt: item.description,
author: default_author,
checksum: checksum
}
attrs = if linked_post_ids == [], do: attrs, else: Map.put(attrs, :linked_post_ids, linked_post_ids)
case Media.import_media(attrs) do
{:ok, %{id: media_id} = media} ->
link_media(linked_post_ids, media_id)
{:ok, media}
other ->
other
end
end
else
{:error, :missing_source_file}
end
end
defp link_media([], _media_id), do: :ok
defp link_media(post_ids, media_id) when is_list(post_ids) do
Enum.each(post_ids, fn post_id ->
try do
Media.link_media_to_post(media_id, post_id)
rescue
_ -> :ok
catch
_, _ -> :ok
end
end)
:ok
end
defp parent_post_ids(item, result) do
case Map.get(item, :parent_wp_id) do
nil -> []
0 -> []
wp_id ->
case Map.get(result.wp_id_to_post_id, wp_id) do
nil -> []
post_id -> [post_id]
end
end
end
defp track_wp_id(result, %{wp_id: wp_id}, %{id: post_id}) when is_integer(wp_id) and not is_nil(post_id) do
update_in(result, [:wp_id_to_post_id], &Map.put(&1, wp_id, post_id))
end
defp track_wp_id(result, _item, _post), do: result
defp maybe_publish(post_id, item) do
case item.wp_status do
"publish" -> Posts.publish_post(post_id)
_other -> {:ok, Repo.get!(Post, post_id)}
end
end
defp prepare_created_post(post_id, item, tag_mapping, category_mapping) do
case Repo.get(Post, post_id) do
nil ->
{:error, :not_found}
%Post{} = post ->
desired_slug = desired_slug(post, item)
created_at = parse_timestamp(item.created_at) || post.created_at
updated_at = parse_timestamp(item.updated_at) || created_at
published_at = parse_timestamp(item.published_at) || created_at
post
|> Post.changeset(%{
slug: desired_slug,
title: item.title,
excerpt: item.excerpt,
content: item.content_markdown,
author: item.author,
tags: resolve_taxonomy(item.tags, tag_mapping),
categories: resolve_taxonomy(item.categories, category_mapping),
checksum: item.content_checksum,
created_at: created_at,
updated_at: updated_at,
published_at: if(item.wp_status == "publish", do: published_at, else: nil)
})
|> Repo.update()
|> case do
{:ok, _updated} -> :ok
error -> error
end
end
end
defp desired_slug(post, item) do
if item.status == "conflict" and resolve_conflict(item) == "import" do
post.slug
else
item.slug || post.slug
end
end
defp post_create_attrs(project_id, item, default_author, tag_mapping, category_mapping) do
%{
project_id: project_id,
title: item.title,
excerpt: item.excerpt,
content: item.content_markdown,
author: item.author || default_author,
tags: resolve_taxonomy(item.tags, tag_mapping),
categories: resolve_taxonomy(item.categories, category_mapping),
checksum: item.content_checksum
}
end
defp maybe_apply_page_category(item, :pages) do
categories = (Map.get(item, :categories) || []) |> Enum.uniq() |> Enum.concat(["page"]) |> Enum.uniq()
%{item | categories: categories}
end
defp maybe_apply_page_category(item, _bucket), do: item
defp build_taxonomy_mapping(items) do
Enum.reduce(items, %{}, fn item, acc ->
key = item.name |> to_string() |> String.downcase()
resolved =
cond do
present_string?(Map.get(item, :mapped_to)) -> String.downcase(item.mapped_to)
true -> key
end
Map.put(acc, key, %{resolved: resolved, needs_creation: not item.exists_in_project and not present_string?(Map.get(item, :mapped_to))})
end)
end
defp resolve_taxonomy(items, mapping) when is_list(items) do
items
|> Enum.map(fn item ->
key = item |> to_string() |> String.downcase()
case Map.get(mapping, key) do
%{resolved: resolved} -> resolved
_ -> key
end
end)
|> Enum.uniq()
end
defp resolve_taxonomy(_items, _mapping), do: []
defp resolve_conflict(item) do
raw = Map.get(item, :resolution)
normalize_resolution(raw)
end
defp normalize_resolution("ignore"), do: "ignore"
defp normalize_resolution("skip"), do: "ignore"
defp normalize_resolution("overwrite"), do: "overwrite"
defp normalize_resolution("merge"), do: "overwrite"
defp normalize_resolution("import"), do: "import"
defp normalize_resolution(_other), do: "ignore"
defp import_items(report, bucket) do
items = get_in(report, [:items, bucket]) || []
details = get_in(report, [:details, bucket]) || []
if details == [] do
Enum.map(items, &normalize_item/1)
else
detail_index =
details
|> Enum.map(&normalize_item/1)
|> Map.new(fn item -> {item_identity(item), item} end)
Enum.map(items, fn item ->
normalized_item = normalize_item(item)
identity = item_identity(normalized_item)
detail_item = Map.get(detail_index, identity, normalized_item)
if Map.has_key?(normalized_item, :resolution) do
%{detail_item | resolution: normalized_item.resolution}
else
detail_item
end
end)
end
end
defp item_identity(%{item_type: "media", filename: filename}), do: {:media, filename}
defp item_identity(%{item_type: item_type, slug: slug}), do: {item_type, slug}
defp normalize_report(report) when is_map(report) do
report
|> Enum.map(fn {key, value} ->
normalized_key = if(is_binary(key), do: String.to_atom(key), else: key)
{normalized_key, normalize_report(value)}
end)
|> Map.new()
end
defp normalize_report(report) when is_list(report), do: Enum.map(report, &normalize_report/1)
defp normalize_report(report), do: report
defp normalize_item(item) do
normalize_report(item)
end
defp parse_timestamp(nil), do: nil
defp parse_timestamp(value) when is_integer(value), do: value
defp parse_timestamp(value) when is_binary(value) do
value
|> String.replace(" ", "T")
|> NaiveDateTime.from_iso8601()
|> case do
{:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC") |> DateTime.to_unix(:millisecond)
_other -> nil
end
end
defp parse_timestamp(_value), do: nil
defp uploads_source_path(relative_path, uploads_folder_path)
defp uploads_source_path(relative_path, uploads_folder_path)
when is_binary(relative_path) and is_binary(uploads_folder_path) and uploads_folder_path != "" do
Path.join(uploads_folder_path, relative_path)
end
defp uploads_source_path(_relative_path, _uploads_folder_path), do: nil
defp notify_progress(callback, phase, current, total, detail, started_at) when is_function(callback, 4) do
eta = compute_eta(current, total, started_at)
try do
callback.(phase, current, total, %{detail: detail, eta: eta})
rescue
_error ->
try do
callback.(phase, current, total, detail)
rescue
_error -> :ok
end
end
:ok
end
defp compute_eta(current, total, started_at) when is_integer(current) and is_integer(total) and current > 0 and total > 0 and current <= total do
elapsed = System.monotonic_time(:millisecond) - started_at
if current >= total, do: 0, else: trunc(elapsed / current * (total - current))
end
defp compute_eta(_current, _total, _started_at), do: nil
defp md5(binary) do
:md5
|> :crypto.hash(binary)
|> Base.encode16(case: :lower)
end
defp present_string?(value) when is_binary(value) and value != "", do: true
defp present_string?(_value), do: false
defp project_default_author(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
Map.get(metadata, :default_author)
end
end

View File

@@ -21,6 +21,13 @@ defmodule BDS.MCP do
@page_size 50 @page_size 50
@proposal_ttl_app_ms 30 * 60 * 1000 @proposal_ttl_app_ms 30 * 60 * 1000
@typedoc "Tool descriptor returned by `list_tools/0`."
@type tool_descriptor :: %{name: String.t(), annotations: map()}
@typedoc "Resource descriptor returned by `list_resources/0`."
@type resource_descriptor :: %{name: String.t(), uri: String.t()}
@spec list_tools() :: [tool_descriptor()]
def list_tools do def list_tools do
[ [
tool("check_term", true), tool("check_term", true),
@@ -37,6 +44,7 @@ defmodule BDS.MCP do
] ]
end end
@spec list_resources() :: [resource_descriptor()]
def list_resources do def list_resources do
[ [
%{name: "posts", uri: "bds://posts"}, %{name: "posts", uri: "bds://posts"},
@@ -46,6 +54,7 @@ defmodule BDS.MCP do
] ]
end end
@spec call_tool(String.t(), map()) :: {:ok, term()} | {:error, term()}
def call_tool(name, params) when is_binary(name) and is_map(params) do def call_tool(name, params) when is_binary(name) and is_map(params) do
ProposalStore.ensure_started() ProposalStore.ensure_started()
@@ -65,6 +74,7 @@ defmodule BDS.MCP do
end end
end end
@spec read_resource(String.t()) :: {:ok, term()} | {:error, term()}
def read_resource(uri) when is_binary(uri) do def read_resource(uri) when is_binary(uri) do
ProposalStore.ensure_started() ProposalStore.ensure_started()
@@ -79,6 +89,7 @@ defmodule BDS.MCP do
end end
end end
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
def validate_template(source) when is_binary(source) do def validate_template(source) when is_binary(source) do
case Liquex.parse(source) do case Liquex.parse(source) do
{:ok, _ast} -> {:ok, %{valid: true, errors: []}} {:ok, _ast} -> {:ok, %{valid: true, errors: []}}
@@ -342,10 +353,10 @@ defmodule BDS.MCP do
proposal.data["template_id"] |> Templates.publish_template() proposal.data["template_id"] |> Templates.publish_template()
"propose_media_metadata" -> "propose_media_metadata" ->
Media.update_media(proposal.data["media_id"], atomize_keys(proposal.data["changes"])) Media.update_media(proposal.data["media_id"], proposal.data["changes"] || %{})
"propose_post_metadata" -> "propose_post_metadata" ->
Posts.update_post(proposal.data["post_id"], atomize_keys(proposal.data["changes"])) Posts.update_post(proposal.data["post_id"], proposal.data["changes"] || %{})
_other -> _other ->
{:error, :unsupported_proposal} {:error, :unsupported_proposal}
@@ -635,10 +646,6 @@ defmodule BDS.MCP do
defp parse_template_kind("not_found"), do: :not_found defp parse_template_kind("not_found"), do: :not_found
defp parse_template_kind("partial"), do: :partial defp parse_template_kind("partial"), do: :partial
defp atomize_keys(map) when is_map(map) do
Map.new(map, fn {key, value} -> {String.to_atom(key), value} end)
end
defp sanitize(%_struct{} = struct) do defp sanitize(%_struct{} = struct) do
struct struct
|> Map.from_struct() |> Map.from_struct()

View File

@@ -7,12 +7,20 @@ defmodule BDS.Media do
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.Media.Translation alias BDS.Media.Translation
alias BDS.Persistence alias BDS.Persistence
alias BDS.Posts.PostMedia
alias BDS.Projects alias BDS.Projects
alias BDS.Rebuild alias BDS.Rebuild
alias BDS.Repo alias BDS.Repo
alias BDS.Search alias BDS.Search
alias BDS.Sidecar alias BDS.Sidecar
@typedoc "An attribute map that may use atom or string keys."
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@typedoc "Options accepted by long-running rebuild operations."
@type rebuild_opts :: keyword()
@spec import_media(attrs()) :: {:ok, Media.t()} | {:error, Ecto.Changeset.t() | term()}
def import_media(attrs) do def import_media(attrs) do
project = Projects.get_project!(attr(attrs, :project_id)) project = Projects.get_project!(attr(attrs, :project_id))
source_path = attr(attrs, :source_path) source_path = attr(attrs, :source_path)
@@ -26,45 +34,48 @@ defmodule BDS.Media do
destination = Path.join(Projects.project_data_dir(project), file_path) destination = Path.join(Projects.project_data_dir(project), file_path)
stat = File.stat!(source_path) stat = File.stat!(source_path)
Repo.transaction(fn -> :ok = File.mkdir_p(Path.dirname(destination))
media = :ok = File.cp(source_path, destination)
%Media{}
|> Media.changeset(%{
id: Ecto.UUID.generate(),
project_id: project.id,
filename: file_name,
original_name: original_name,
mime_type: mime_type,
size: stat.size,
width: attr(attrs, :width) || width,
height: attr(attrs, :height) || height,
title: attr(attrs, :title),
alt: attr(attrs, :alt),
caption: attr(attrs, :caption),
author: attr(attrs, :author),
language: attr(attrs, :language),
file_path: file_path,
sidecar_path: sidecar_path,
checksum: attr(attrs, :checksum),
tags: attr(attrs, :tags) || [],
created_at: now,
updated_at: now
})
|> Repo.insert!()
:ok = File.mkdir_p(Path.dirname(destination)) case Repo.transaction(fn ->
:ok = File.cp(source_path, destination) %Media{}
:ok = write_sidecar(project, media) |> Media.changeset(%{
:ok = ensure_thumbnails(project, media) id: Ecto.UUID.generate(),
:ok = Search.sync_media(media) project_id: project.id,
media filename: file_name,
end) original_name: original_name,
|> case do mime_type: mime_type,
{:ok, media} -> {:ok, media} size: stat.size,
{:error, reason} -> {:error, reason} width: attr(attrs, :width) || width,
height: attr(attrs, :height) || height,
title: attr(attrs, :title),
alt: attr(attrs, :alt),
caption: attr(attrs, :caption),
author: attr(attrs, :author),
language: attr(attrs, :language),
file_path: file_path,
sidecar_path: sidecar_path,
checksum: attr(attrs, :checksum),
tags: attr(attrs, :tags) || [],
created_at: now,
updated_at: now
})
|> Repo.insert!()
end) do
{:ok, media} ->
:ok = write_sidecar(project, media)
:ok = ensure_thumbnails(project, media)
:ok = Search.sync_media(media)
{:ok, media}
{:error, reason} ->
_ = File.rm(destination)
{:error, reason}
end end
end end
@spec update_media(String.t(), attrs()) ::
{:ok, Media.t()} | {:error, :not_found | Ecto.Changeset.t()}
def update_media(media_id, attrs) do def update_media(media_id, attrs) do
case Repo.get(Media, media_id) do case Repo.get(Media, media_id) do
nil -> nil ->
@@ -85,23 +96,23 @@ defmodule BDS.Media do
project = Projects.get_project!(media.project_id) project = Projects.get_project!(media.project_id)
Repo.transaction(fn -> case Repo.transaction(fn ->
updated_media = media
media |> Media.changeset(updates)
|> Media.changeset(updates) |> Repo.update!()
|> Repo.update!() end) do
{:ok, updated_media} ->
:ok = write_sidecar(project, updated_media)
:ok = Search.sync_media(updated_media)
{:ok, updated_media}
:ok = write_sidecar(project, updated_media) {:error, reason} ->
:ok = Search.sync_media(updated_media) {:error, reason}
updated_media
end)
|> case do
{:ok, updated_media} -> {:ok, updated_media}
{:error, reason} -> {:error, reason}
end end
end end
end end
@spec sync_media_sidecar(String.t()) :: :ok | {:error, :not_found | term()}
def sync_media_sidecar(media_id) do def sync_media_sidecar(media_id) do
case Repo.get(Media, media_id) do case Repo.get(Media, media_id) do
nil -> nil ->
@@ -114,6 +125,8 @@ defmodule BDS.Media do
end end
end end
@spec sync_media_from_sidecar(String.t()) ::
{:ok, Media.t()} | {:error, :not_found | term()}
def sync_media_from_sidecar(media_id) do def sync_media_from_sidecar(media_id) do
case Repo.get(Media, media_id) do case Repo.get(Media, media_id) do
nil -> nil ->
@@ -131,6 +144,8 @@ defmodule BDS.Media do
end end
end end
@spec sync_media_translation_sidecar(String.t()) ::
{:ok, Translation.t()} | {:error, :not_found | term()}
def sync_media_translation_sidecar(translation_id) do def sync_media_translation_sidecar(translation_id) do
case Repo.get(Translation, translation_id) do case Repo.get(Translation, translation_id) do
nil -> nil ->
@@ -144,6 +159,8 @@ defmodule BDS.Media do
end end
end end
@spec sync_media_translation_from_sidecar(String.t()) ::
{:ok, Translation.t()} | {:error, :not_found | term()}
def sync_media_translation_from_sidecar(translation_id) do def sync_media_translation_from_sidecar(translation_id) do
case Repo.get(Translation, translation_id) do case Repo.get(Translation, translation_id) do
nil -> nil ->
@@ -171,6 +188,8 @@ defmodule BDS.Media do
end end
end end
@spec import_orphan_media_sidecar(String.t(), String.t()) ::
{:ok, Media.t()} | {:error, term()}
def import_orphan_media_sidecar(project_id, relative_path) do def import_orphan_media_sidecar(project_id, relative_path) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
sidecar_path = Path.join(Projects.project_data_dir(project), relative_path) sidecar_path = Path.join(Projects.project_data_dir(project), relative_path)
@@ -182,6 +201,8 @@ defmodule BDS.Media do
end end
end end
@spec import_orphan_media_translation_sidecar(String.t(), String.t()) ::
{:ok, Translation.t()} | {:error, term()}
def import_orphan_media_translation_sidecar(project_id, relative_path) do def import_orphan_media_translation_sidecar(project_id, relative_path) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
sidecar_path = Path.join(Projects.project_data_dir(project), relative_path) sidecar_path = Path.join(Projects.project_data_dir(project), relative_path)
@@ -214,6 +235,7 @@ defmodule BDS.Media do
end end
end end
@spec delete_media(String.t()) :: {:ok, :deleted} | {:error, :not_found}
def delete_media(media_id) do def delete_media(media_id) do
case Repo.get(Media, media_id) do case Repo.get(Media, media_id) do
nil -> nil ->
@@ -244,6 +266,8 @@ defmodule BDS.Media do
end end
end end
@spec upsert_media_translation(String.t(), String.t() | atom(), attrs()) ::
{:ok, Translation.t()} | {:error, :not_found | Ecto.Changeset.t()}
def upsert_media_translation(media_id, language, attrs) do def upsert_media_translation(media_id, language, attrs) do
case Repo.get(Media, media_id) do case Repo.get(Media, media_id) do
nil -> nil ->
@@ -269,23 +293,24 @@ defmodule BDS.Media do
updated_at: now updated_at: now
} }
Repo.transaction(fn -> case Repo.transaction(fn ->
updated_translation = translation
translation |> Translation.changeset(translation_attrs)
|> Translation.changeset(translation_attrs) |> Repo.insert_or_update!()
|> Repo.insert_or_update!() end) do
{:ok, updated_translation} ->
:ok = write_translation_sidecar(project, media, updated_translation)
:ok = Search.sync_media(media.id)
{:ok, updated_translation}
:ok = write_translation_sidecar(project, media, updated_translation) {:error, reason} ->
:ok = Search.sync_media(media.id) {:error, reason}
updated_translation
end)
|> case do
{:ok, updated_translation} -> {:ok, updated_translation}
{:error, reason} -> {:error, reason}
end end
end end
end end
@spec delete_media_translation(String.t(), String.t() | atom()) ::
{:ok, boolean()} | {:error, :not_found | term()}
def delete_media_translation(media_id, language) do def delete_media_translation(media_id, language) do
normalized_language = language |> to_string() |> String.trim() |> String.downcase() normalized_language = language |> to_string() |> String.trim() |> String.downcase()
@@ -301,21 +326,22 @@ defmodule BDS.Media do
translation -> translation ->
project = Projects.get_project!(media.project_id) project = Projects.get_project!(media.project_id)
Repo.transaction(fn -> case Repo.transaction(fn -> Repo.delete!(translation) end) do
Repo.delete!(translation) {:ok, _deleted} ->
delete_file_if_present(media.project_id, translation_sidecar_path(media, normalized_language)) delete_file_if_present(media.project_id, translation_sidecar_path(media, normalized_language))
:ok = Search.sync_media(media) :ok = Search.sync_media(media)
:ok = write_sidecar(project, media) :ok = write_sidecar(project, media)
true {:ok, true}
end)
|> case do {:error, reason} ->
{:ok, deleted?} -> {:ok, deleted?} {:error, reason}
{:error, reason} -> {:error, reason}
end end
end end
end end
end end
@spec replace_media_file(String.t(), String.t()) ::
{:ok, Media.t() | nil} | {:error, :not_found | Ecto.Changeset.t() | term()}
def replace_media_file(media_id, new_source_path) do def replace_media_file(media_id, new_source_path) do
case Repo.get(Media, media_id) do case Repo.get(Media, media_id) do
nil -> nil ->
@@ -334,35 +360,38 @@ defmodule BDS.Media do
else else
mime_type = media.mime_type || detect_mime(media.original_name || media.filename) mime_type = media.mime_type || detect_mime(media.original_name || media.filename)
{width, height} = image_dimensions(new_source_path, mime_type) {width, height} = image_dimensions(new_source_path, mime_type)
previous_destination_backup = destination <> ".bak"
_ = File.rename(destination, previous_destination_backup)
:ok = File.cp(new_source_path, destination)
Repo.transaction(fn -> case Repo.transaction(fn ->
:ok = File.cp(new_source_path, destination) media
|> Media.changeset(%{
size: stat.size,
width: width || media.width,
height: height || media.height,
checksum: checksum,
updated_at: Persistence.now_ms()
})
|> Repo.update!()
end) do
{:ok, updated_media} ->
_ = File.rm(previous_destination_backup)
:ok = write_sidecar(project, updated_media)
:ok = ensure_thumbnails(project, updated_media)
:ok = Search.sync_media(updated_media)
{:ok, updated_media}
updated_media = {:error, reason} ->
media _ = File.rename(previous_destination_backup, destination)
|> Media.changeset(%{ {:error, reason}
size: stat.size,
width: width || media.width,
height: height || media.height,
checksum: checksum,
updated_at: Persistence.now_ms()
})
|> Repo.update!()
:ok = write_sidecar(project, updated_media)
:ok = ensure_thumbnails(project, updated_media)
:ok = Search.sync_media(updated_media)
updated_media
end)
|> case do
{:ok, updated_media} -> {:ok, updated_media}
{:error, reason} -> {:error, reason}
end end
end end
end end
end end
end end
@spec list_media_translations(String.t()) :: [Translation.t()]
def list_media_translations(media_id) when is_binary(media_id) do def list_media_translations(media_id) when is_binary(media_id) do
Repo.all( Repo.all(
from translation in Translation, from translation in Translation,
@@ -371,21 +400,24 @@ defmodule BDS.Media do
) )
end end
@spec list_linked_posts(String.t()) :: [%{post_id: String.t(), title: String.t(), sort_order: integer()}]
def list_linked_posts(media_id) when is_binary(media_id) do def list_linked_posts(media_id) when is_binary(media_id) do
Repo.all( Repo.all(
from post in BDS.Posts.Post, from post in BDS.Posts.Post,
join: post_media in "post_media", join: pm in PostMedia,
on: post_media.post_id == post.id, on: pm.post_id == post.id,
where: post_media.media_id == ^media_id, where: pm.media_id == ^media_id,
order_by: [asc: post_media.sort_order, asc: post.updated_at], order_by: [asc: pm.sort_order, asc: post.updated_at],
select: %{ select: %{
post_id: post.id, post_id: post.id,
title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id), title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id),
sort_order: post_media.sort_order sort_order: pm.sort_order
} }
) )
end end
@spec link_media_to_post(String.t(), String.t()) ::
{:ok, :linked} | {:error, :not_found | term()}
def link_media_to_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do def link_media_to_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do
case {Repo.get(Media, media_id), Repo.get(BDS.Posts.Post, post_id)} do case {Repo.get(Media, media_id), Repo.get(BDS.Posts.Post, post_id)} do
{nil, _post} -> {nil, _post} ->
@@ -397,33 +429,38 @@ defmodule BDS.Media do
{%Media{} = media, %BDS.Posts.Post{} = post} -> {%Media{} = media, %BDS.Posts.Post{} = post} ->
project = Projects.get_project!(media.project_id) project = Projects.get_project!(media.project_id)
Repo.transaction(fn -> case Repo.transaction(fn ->
case Repo.query("SELECT 1 FROM post_media WHERE post_id = ? AND media_id = ? LIMIT 1", [post.id, media.id]) do if Repo.exists?(from pm in PostMedia, where: pm.post_id == ^post.id and pm.media_id == ^media.id) do
{:ok, %{rows: [[1]]}} -> :already_linked
:already_linked else
sort_order = next_sort_order(media.id)
_other -> %PostMedia{}
sort_order = next_sort_order(media.id) |> PostMedia.changeset(%{
id: Ecto.UUID.generate(),
project_id: media.project_id,
post_id: post.id,
media_id: media.id,
sort_order: sort_order,
created_at: Persistence.now_ms()
})
|> Repo.insert!()
{:ok, _result} = :linked
Repo.query( end
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?)", end) do
[Ecto.UUID.generate(), media.project_id, post.id, media.id, sort_order, Persistence.now_ms()] {:ok, _result} ->
) :ok = write_sidecar(project, media)
{:ok, :linked}
:linked {:error, reason} ->
end {:error, reason}
:ok = write_sidecar(project, media)
:ok
end)
|> case do
{:ok, :ok} -> {:ok, :linked}
{:error, reason} -> {:error, reason}
end end
end end
end end
@spec unlink_media_from_post(String.t(), String.t()) ::
{:ok, :unlinked} | {:error, :not_found | term()}
def unlink_media_from_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do def unlink_media_from_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do
case Repo.get(Media, media_id) do case Repo.get(Media, media_id) do
nil -> nil ->
@@ -432,18 +469,25 @@ defmodule BDS.Media do
%Media{} = media -> %Media{} = media ->
project = Projects.get_project!(media.project_id) project = Projects.get_project!(media.project_id)
Repo.transaction(fn -> case Repo.transaction(fn ->
{:ok, _result} = Repo.query("DELETE FROM post_media WHERE media_id = ? AND post_id = ?", [media.id, post_id]) {_count, _} =
:ok = write_sidecar(project, media) Repo.delete_all(
:ok from pm in PostMedia, where: pm.media_id == ^media.id and pm.post_id == ^post_id
end) )
|> case do
{:ok, :ok} -> {:ok, :unlinked} :ok
{:error, reason} -> {:error, reason} end) do
{:ok, :ok} ->
:ok = write_sidecar(project, media)
{:ok, :unlinked}
{:error, reason} ->
{:error, reason}
end end
end end
end end
@spec thumbnail_paths(Media.t()) :: %{required(atom()) => String.t()}
def thumbnail_paths(%Media{id: id}) do def thumbnail_paths(%Media{id: id}) do
prefix = String.slice(id, 0, 2) prefix = String.slice(id, 0, 2)
@@ -455,6 +499,7 @@ defmodule BDS.Media do
} }
end end
@spec regenerate_thumbnails(String.t()) :: {:ok, Media.t()} | {:error, :not_found | term()}
def regenerate_thumbnails(media_id) do def regenerate_thumbnails(media_id) do
case Repo.get(Media, media_id) do case Repo.get(Media, media_id) do
nil -> nil ->
@@ -467,6 +512,8 @@ defmodule BDS.Media do
end end
end end
@spec regenerate_missing_thumbnails(String.t(), rebuild_opts()) ::
%{processed: non_neg_integer(), generated: non_neg_integer(), failed: non_neg_integer()}
def regenerate_missing_thumbnails(project_id, opts \\ []) do def regenerate_missing_thumbnails(project_id, opts \\ []) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
on_progress = progress_callback(opts) on_progress = progress_callback(opts)
@@ -518,6 +565,7 @@ defmodule BDS.Media do
end) end)
end end
@spec rebuild_media_from_files(String.t(), rebuild_opts()) :: {:ok, [Media.t()]}
def rebuild_media_from_files(project_id, opts \\ []) do def rebuild_media_from_files(project_id, opts \\ []) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
on_progress = progress_callback(opts) on_progress = progress_callback(opts)
@@ -866,15 +914,21 @@ defmodule BDS.Media do
end end
defp linked_post_ids(media_id) do defp linked_post_ids(media_id) do
case Repo.query("SELECT post_id FROM post_media WHERE media_id = ? ORDER BY sort_order ASC, post_id ASC", [media_id]) do Repo.all(
{:ok, %{rows: rows}} -> Enum.map(rows, fn [post_id] -> post_id end) from pm in PostMedia,
{:error, _reason} -> [] where: pm.media_id == ^media_id,
end order_by: [asc: pm.sort_order, asc: pm.post_id],
select: pm.post_id
)
end end
defp next_sort_order(media_id) do defp next_sort_order(media_id) do
case Repo.query("SELECT COALESCE(MAX(sort_order), -1) FROM post_media WHERE media_id = ?", [media_id]) do case Repo.one(
{:ok, %{rows: [[value]]}} when is_integer(value) -> value + 1 from pm in PostMedia,
where: pm.media_id == ^media_id,
select: max(pm.sort_order)
) do
value when is_integer(value) -> value + 1
_other -> 0 _other -> 0
end end
end end

View File

@@ -9,6 +9,29 @@ defmodule BDS.Media.Media do
@primary_key {:id, :string, autogenerate: false} @primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string @foreign_key_type :string
@type t :: %__MODULE__{
id: String.t() | nil,
project_id: String.t() | nil,
project: term(),
filename: String.t() | nil,
original_name: String.t() | nil,
mime_type: String.t() | nil,
size: integer() | nil,
width: integer() | nil,
height: integer() | nil,
title: String.t() | nil,
alt: String.t() | nil,
caption: String.t() | nil,
author: String.t() | nil,
language: String.t() | nil,
file_path: String.t() | nil,
sidecar_path: String.t() | nil,
checksum: String.t() | nil,
tags: [String.t()],
created_at: integer() | nil,
updated_at: integer() | nil
}
schema "media" do schema "media" do
belongs_to :project, BDS.Projects.Project, type: :string belongs_to :project, BDS.Projects.Project, type: :string
@@ -31,6 +54,7 @@ defmodule BDS.Media.Media do
field :updated_at, :integer field :updated_at, :integer
end end
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(media, attrs) do def changeset(media, attrs) do
media media
|> cast( |> cast(

View File

@@ -7,6 +7,19 @@ defmodule BDS.Media.Translation do
@primary_key {:id, :string, autogenerate: false} @primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string @foreign_key_type :string
@type t :: %__MODULE__{
id: String.t() | nil,
translation_for: String.t() | nil,
media: term(),
project_id: String.t() | nil,
language: String.t() | nil,
title: String.t() | nil,
alt: String.t() | nil,
caption: String.t() | nil,
created_at: integer() | nil,
updated_at: integer() | nil
}
schema "media_translations" do schema "media_translations" do
belongs_to :media, BDS.Media.Media, belongs_to :media, BDS.Media.Media,
foreign_key: :translation_for, foreign_key: :translation_for,
@@ -22,6 +35,7 @@ defmodule BDS.Media.Translation do
field :updated_at, :integer field :updated_at, :integer
end end
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(translation, attrs) do def changeset(translation, attrs) do
translation translation
|> cast( |> cast(

View File

@@ -36,16 +36,26 @@ defmodule BDS.Metadata do
"zinc" "zinc"
]) ])
@typedoc "Project metadata state map."
@type metadata_state :: map()
@typedoc "Attribute map for `update_project_metadata/2`."
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@spec get_project_metadata(String.t()) :: {:ok, metadata_state()}
def get_project_metadata(project_id) do def get_project_metadata(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
{:ok, load_state(project)} {:ok, load_state(project)}
end end
@spec read_project_metadata_from_filesystem(String.t()) :: {:ok, metadata_state()}
def read_project_metadata_from_filesystem(project_id) do def read_project_metadata_from_filesystem(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
{:ok, load_state_from_filesystem(project)} {:ok, load_state_from_filesystem(project)}
end end
@spec update_project_metadata(String.t(), attrs()) ::
{:ok, metadata_state()} | {:error, term()}
def update_project_metadata(project_id, attrs) do def update_project_metadata(project_id, attrs) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
state = load_state(project) state = load_state(project)
@@ -85,6 +95,7 @@ defmodule BDS.Metadata do
|> maybe_backfill_embeddings(project_id, state, project_metadata) |> maybe_backfill_embeddings(project_id, state, project_metadata)
end end
@spec add_category(String.t(), String.t()) :: {:ok, metadata_state()} | {:error, term()}
def add_category(project_id, name) do def add_category(project_id, name) do
update_state(project_id, fn project, state, now -> update_state(project_id, fn project, state, now ->
categories = categories =
@@ -100,6 +111,7 @@ defmodule BDS.Metadata do
end) end)
end end
@spec remove_category(String.t(), String.t()) :: {:ok, metadata_state()} | {:error, term()}
def remove_category(project_id, name) do def remove_category(project_id, name) do
update_state(project_id, fn project, state, now -> update_state(project_id, fn project, state, now ->
categories = Enum.reject(state.categories, &(&1 == name)) categories = Enum.reject(state.categories, &(&1 == name))
@@ -113,6 +125,8 @@ defmodule BDS.Metadata do
end) end)
end end
@spec update_category_settings(String.t(), String.t(), map()) ::
{:ok, metadata_state()} | {:error, term()}
def update_category_settings(project_id, category, settings) do def update_category_settings(project_id, category, settings) do
update_state(project_id, fn project, state, now -> update_state(project_id, fn project, state, now ->
normalized = normalize_category_settings(settings) normalized = normalize_category_settings(settings)
@@ -124,6 +138,8 @@ defmodule BDS.Metadata do
end) end)
end end
@spec set_publishing_preferences(String.t(), map()) ::
{:ok, metadata_state()} | {:error, term()}
def set_publishing_preferences(project_id, prefs) do def set_publishing_preferences(project_id, prefs) do
update_state(project_id, fn project, state, now -> update_state(project_id, fn project, state, now ->
publishing_preferences = normalize_publishing_preferences(prefs) publishing_preferences = normalize_publishing_preferences(prefs)
@@ -133,6 +149,8 @@ defmodule BDS.Metadata do
end) end)
end end
@spec sync_project_metadata_from_filesystem(String.t()) ::
{:ok, metadata_state()} | {:error, term()}
def sync_project_metadata_from_filesystem(project_id) do def sync_project_metadata_from_filesystem(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
now = Persistence.now_ms() now = Persistence.now_ms()
@@ -167,6 +185,7 @@ defmodule BDS.Metadata do
|> unwrap_transaction() |> unwrap_transaction()
end end
@spec flush_project_metadata_to_filesystem(String.t()) :: {:ok, metadata_state()}
def flush_project_metadata_to_filesystem(project_id) do def flush_project_metadata_to_filesystem(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
state = load_state(project) state = load_state(project)

View File

@@ -13,6 +13,7 @@ defmodule BDS.Posts do
alias BDS.PostLinks alias BDS.PostLinks
alias BDS.Posts.Link alias BDS.Posts.Link
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.PostMedia
alias BDS.Posts.Translation alias BDS.Posts.Translation
alias BDS.Projects alias BDS.Projects
alias BDS.Rebuild alias BDS.Rebuild
@@ -21,6 +22,35 @@ defmodule BDS.Posts do
alias BDS.Slug alias BDS.Slug
alias BDS.Tasks alias BDS.Tasks
@typedoc "An attribute map that may use atom or string keys."
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@typedoc "Options accepted by long-running rebuild operations."
@type rebuild_opts :: keyword()
@typedoc "Aggregate counts returned by `dashboard_stats/1`."
@type dashboard_stats :: %{
total_posts: non_neg_integer(),
draft_count: non_neg_integer(),
published_count: non_neg_integer(),
archived_count: non_neg_integer()
}
@typedoc "Per-month post count entry returned by `post_counts_by_year_month/1`."
@type month_count :: %{year: integer(), month: integer(), count: non_neg_integer()}
@typedoc "Translation validation report returned by `validate_translations/2`."
@type translation_validation_report :: %{
checked_database_row_count: non_neg_integer(),
checked_filesystem_file_count: non_neg_integer(),
invalid_database_rows: [map()],
invalid_filesystem_files: [map()],
missing: [map()],
orphan_files: [map()],
do_not_translate_posts: [map()]
}
@spec create_post(attrs()) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()}
def create_post(attrs) do def create_post(attrs) do
now = Persistence.now_ms() now = Persistence.now_ms()
project_id = attr(attrs, :project_id) project_id = attr(attrs, :project_id)
@@ -66,6 +96,8 @@ defmodule BDS.Posts do
end end
end end
@spec update_post(String.t(), attrs()) ::
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
def update_post(post_id, attrs) do def update_post(post_id, attrs) do
case Repo.get(Post, post_id) do case Repo.get(Post, post_id) do
nil -> nil ->
@@ -107,6 +139,8 @@ defmodule BDS.Posts do
end end
end end
@spec publish_post(String.t()) ::
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
def publish_post(post_id) do def publish_post(post_id) do
case Repo.get(Post, post_id) do case Repo.get(Post, post_id) do
nil -> nil ->
@@ -149,6 +183,7 @@ defmodule BDS.Posts do
end end
end end
@spec rebuild_posts_from_files(String.t(), rebuild_opts()) :: {:ok, [Post.t()]}
def rebuild_posts_from_files(project_id, opts \\ []) do def rebuild_posts_from_files(project_id, opts \\ []) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
on_progress = progress_callback(opts) on_progress = progress_callback(opts)
@@ -200,6 +235,8 @@ defmodule BDS.Posts do
{:ok, posts} {:ok, posts}
end end
@spec discard_post_changes(String.t()) ::
{:ok, Post.t()} | {:error, :not_found}
def discard_post_changes(post_id) do def discard_post_changes(post_id) do
case Repo.get(Post, post_id) do case Repo.get(Post, post_id) do
nil -> nil ->
@@ -222,6 +259,7 @@ defmodule BDS.Posts do
end end
end end
@spec editor_body(Post.t() | Translation.t() | term()) :: String.t()
def editor_body(%Post{content: content}) when is_binary(content), do: content def editor_body(%Post{content: content}) when is_binary(content), do: content
def editor_body(%Post{project_id: project_id, file_path: file_path}) def editor_body(%Post{project_id: project_id, file_path: file_path})
@@ -246,6 +284,7 @@ defmodule BDS.Posts do
def editor_body(_record), do: "" def editor_body(_record), do: ""
@spec sync_post_from_file(String.t()) :: {:ok, Post.t()} | {:error, :not_found}
def sync_post_from_file(post_id) do def sync_post_from_file(post_id) do
case Repo.get(Post, post_id) do case Repo.get(Post, post_id) do
nil -> nil ->
@@ -268,6 +307,8 @@ defmodule BDS.Posts do
end end
end end
@spec sync_post_translation_from_file(String.t()) ::
{:ok, Translation.t()} | {:error, :not_found}
def sync_post_translation_from_file(translation_id) do def sync_post_translation_from_file(translation_id) do
case Repo.get(Translation, translation_id) do case Repo.get(Translation, translation_id) do
nil -> nil ->
@@ -289,6 +330,8 @@ defmodule BDS.Posts do
end end
end end
@spec rewrite_published_post_translation(String.t()) ::
{:ok, Translation.t()} | {:error, :not_found}
def rewrite_published_post_translation(translation_id) do def rewrite_published_post_translation(translation_id) do
case Repo.get(Translation, translation_id) do case Repo.get(Translation, translation_id) do
nil -> nil ->
@@ -305,6 +348,8 @@ defmodule BDS.Posts do
end end
end end
@spec import_orphan_post_file(String.t(), String.t()) ::
{:ok, Post.t()} | {:error, :not_found | :unsupported_file}
def import_orphan_post_file(project_id, relative_path) do def import_orphan_post_file(project_id, relative_path) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
full_path = Path.join(Projects.project_data_dir(project), relative_path) full_path = Path.join(Projects.project_data_dir(project), relative_path)
@@ -327,6 +372,8 @@ defmodule BDS.Posts do
end end
end end
@spec import_orphan_post_translation_file(String.t(), String.t()) ::
{:ok, Translation.t()} | {:error, :not_found | :unsupported_file | :conflict}
def import_orphan_post_translation_file(project_id, relative_path) do def import_orphan_post_translation_file(project_id, relative_path) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
full_path = Path.join(Projects.project_data_dir(project), relative_path) full_path = Path.join(Projects.project_data_dir(project), relative_path)
@@ -359,6 +406,7 @@ defmodule BDS.Posts do
end end
end end
@spec delete_post(String.t()) :: {:ok, :deleted} | {:error, :not_found}
def delete_post(post_id) do def delete_post(post_id) do
case Repo.get(Post, post_id) do case Repo.get(Post, post_id) do
nil -> nil ->
@@ -376,6 +424,8 @@ defmodule BDS.Posts do
end end
end end
@spec archive_post(String.t()) ::
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
def archive_post(post_id) do def archive_post(post_id) do
case Repo.get(Post, post_id) do case Repo.get(Post, post_id) do
nil -> nil ->
@@ -402,10 +452,14 @@ defmodule BDS.Posts do
end end
end end
@spec get_post!(String.t()) :: Post.t()
def get_post!(post_id), do: Repo.get!(Post, post_id) def get_post!(post_id), do: Repo.get!(Post, post_id)
@spec get_post_translation!(String.t()) :: Translation.t()
def get_post_translation!(translation_id), do: Repo.get!(Translation, translation_id) def get_post_translation!(translation_id), do: Repo.get!(Translation, translation_id)
@spec publish_post_translation(String.t(), String.t() | atom()) ::
{:ok, Translation.t()} | {:error, :not_found | term()}
def publish_post_translation(post_id, language) do def publish_post_translation(post_id, language) do
normalized_language = language |> to_string() |> String.trim() |> String.downcase() normalized_language = language |> to_string() |> String.trim() |> String.downcase()
@@ -424,6 +478,7 @@ defmodule BDS.Posts do
end end
end end
@spec slug_available(String.t(), String.t(), String.t() | nil) :: boolean()
def slug_available(project_id, slug, exclude_post_id \\ nil) do def slug_available(project_id, slug, exclude_post_id \\ nil) do
normalized_slug = slug |> to_string() |> String.trim() normalized_slug = slug |> to_string() |> String.trim()
@@ -441,6 +496,7 @@ defmodule BDS.Posts do
end end
end end
@spec unique_slug_for_title(String.t(), String.t(), String.t() | nil) :: String.t()
def unique_slug_for_title(project_id, title, exclude_post_id \\ nil) do def unique_slug_for_title(project_id, title, exclude_post_id \\ nil) do
base_slug = title |> default_slug_source() |> Slug.slugify() base_slug = title |> default_slug_source() |> Slug.slugify()
@@ -455,6 +511,7 @@ defmodule BDS.Posts do
end end
end end
@spec dashboard_stats(String.t()) :: dashboard_stats()
def dashboard_stats(project_id) do def dashboard_stats(project_id) do
Repo.all( Repo.all(
from(post in Post, from(post in Post,
@@ -477,6 +534,7 @@ defmodule BDS.Posts do
) )
end end
@spec post_counts_by_year_month(String.t()) :: [month_count()]
def post_counts_by_year_month(project_id) do def post_counts_by_year_month(project_id) do
Repo.all( Repo.all(
from(post in Post, from(post in Post,
@@ -493,6 +551,7 @@ defmodule BDS.Posts do
|> Enum.sort_by(fn %{year: year, month: month} -> {-year, -month} end) |> Enum.sort_by(fn %{year: year, month: month} -> {-year, -month} end)
end end
@spec rebuild_post_links(String.t(), rebuild_opts()) :: :ok
def rebuild_post_links(project_id, opts \\ []) do def rebuild_post_links(project_id, opts \\ []) do
post_ids = Repo.all(from(post in Post, where: post.project_id == ^project_id, select: post.id)) post_ids = Repo.all(from(post in Post, where: post.project_id == ^project_id, select: post.id))
on_progress = progress_callback(opts) on_progress = progress_callback(opts)
@@ -517,6 +576,7 @@ defmodule BDS.Posts do
:ok :ok
end end
@spec list_post_translations(String.t()) :: {:ok, [Translation.t()]}
def list_post_translations(post_id) do def list_post_translations(post_id) do
{:ok, {:ok,
Repo.all( Repo.all(
@@ -526,6 +586,8 @@ defmodule BDS.Posts do
)} )}
end end
@spec upsert_post_translation(String.t(), String.t() | atom(), attrs()) ::
{:ok, Translation.t()} | {:error, :not_found | Ecto.Changeset.t()}
def upsert_post_translation(post_id, language, attrs) do def upsert_post_translation(post_id, language, attrs) do
case Repo.get(Post, post_id) do case Repo.get(Post, post_id) do
nil -> nil ->
@@ -566,6 +628,7 @@ defmodule BDS.Posts do
end end
end end
@spec delete_post_translation(String.t()) :: {:ok, :deleted} | {:error, :not_found}
def delete_post_translation(translation_id) do def delete_post_translation(translation_id) do
case Repo.get(Translation, translation_id) do case Repo.get(Translation, translation_id) do
nil -> nil ->
@@ -579,6 +642,7 @@ defmodule BDS.Posts do
end end
end end
@spec validate_translations(String.t(), rebuild_opts()) :: {:ok, translation_validation_report()}
def validate_translations(project_id, opts \\ []) do def validate_translations(project_id, opts \\ []) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
@@ -657,6 +721,13 @@ defmodule BDS.Posts do
}} }}
end end
@spec fix_invalid_translations(map()) ::
{:ok,
%{
deleted_database_rows: non_neg_integer(),
deleted_files: non_neg_integer(),
flushed_translations: non_neg_integer()
}}
def fix_invalid_translations(report) when is_map(report) do def fix_invalid_translations(report) when is_map(report) do
normalized_report = normalize_translation_validation_report(report) normalized_report = normalize_translation_validation_report(report)
@@ -693,6 +764,7 @@ defmodule BDS.Posts do
}} }}
end end
@spec rewrite_published_post(String.t()) :: :ok
def rewrite_published_post(post_id) do def rewrite_published_post(post_id) do
post = Repo.get!(Post, post_id) post = Repo.get!(Post, post_id)
@@ -1256,7 +1328,6 @@ defmodule BDS.Posts do
%{post_id: post.id, translation_id: saved_translation.id, language: language} %{post_id: post.id, translation_id: saved_translation.id, language: language}
else else
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
other -> {:error, other}
end end
end, end,
auto_translation_task_attrs(post) auto_translation_task_attrs(post)
@@ -1294,7 +1365,6 @@ defmodule BDS.Posts do
%{media_id: media_id, translation_id: saved_translation.id, language: language} %{media_id: media_id, translation_id: saved_translation.id, language: language}
else else
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
other -> {:error, other}
end end
end, end,
auto_translation_task_attrs(post) auto_translation_task_attrs(post)
@@ -1342,10 +1412,12 @@ defmodule BDS.Posts do
end end
defp linked_media_ids(post_id) do defp linked_media_ids(post_id) do
case Repo.query("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY sort_order ASC, media_id ASC", [post_id]) do Repo.all(
{:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end) from pm in PostMedia,
{:error, _reason} -> [] where: pm.post_id == ^post_id,
end order_by: [asc: pm.sort_order, asc: pm.media_id],
select: pm.media_id
)
end end
defp sync_deleted_post_media_sidecar(media_id) do defp sync_deleted_post_media_sidecar(media_id) do

View File

@@ -7,6 +7,16 @@ defmodule BDS.Posts.Link do
@primary_key {:id, :string, autogenerate: false} @primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string @foreign_key_type :string
@type t :: %__MODULE__{
id: String.t() | nil,
source_post_id: String.t() | nil,
target_post_id: String.t() | nil,
source_post: term(),
target_post: term(),
link_text: String.t() | nil,
created_at: integer() | nil
}
schema "post_links" do schema "post_links" do
belongs_to :source_post, BDS.Posts.Post, foreign_key: :source_post_id, references: :id, type: :string belongs_to :source_post, BDS.Posts.Post, foreign_key: :source_post_id, references: :id, type: :string
belongs_to :target_post, BDS.Posts.Post, foreign_key: :target_post_id, references: :id, type: :string belongs_to :target_post, BDS.Posts.Post, foreign_key: :target_post_id, references: :id, type: :string
@@ -15,6 +25,7 @@ defmodule BDS.Posts.Link do
field :created_at, :integer field :created_at, :integer
end end
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(link, attrs) do def changeset(link, attrs) do
link link
|> cast(attrs, [:id, :source_post_id, :target_post_id, :link_text, :created_at]) |> cast(attrs, [:id, :source_post_id, :target_post_id, :link_text, :created_at])

View File

@@ -10,6 +10,35 @@ defmodule BDS.Posts.Post do
@foreign_key_type :string @foreign_key_type :string
@statuses [:draft, :published, :archived] @statuses [:draft, :published, :archived]
@type status :: :draft | :published | :archived
@type t :: %__MODULE__{
id: String.t() | nil,
project_id: String.t() | nil,
project: term(),
title: String.t() | nil,
slug: String.t() | nil,
excerpt: String.t() | nil,
content: String.t() | nil,
status: status(),
author: String.t() | nil,
created_at: integer() | nil,
updated_at: integer() | nil,
published_at: integer() | nil,
file_path: String.t(),
checksum: String.t() | nil,
tags: [String.t()],
categories: [String.t()],
template_slug: String.t() | nil,
language: String.t() | nil,
do_not_translate: boolean(),
published_title: String.t() | nil,
published_content: String.t() | nil,
published_tags: String.t() | nil,
published_categories: String.t() | nil,
published_excerpt: String.t() | nil
}
schema "posts" do schema "posts" do
belongs_to :project, BDS.Projects.Project, type: :string belongs_to :project, BDS.Projects.Project, type: :string
@@ -36,6 +65,7 @@ defmodule BDS.Posts.Post do
field :published_excerpt, :string field :published_excerpt, :string
end end
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(post, attrs) do def changeset(post, attrs) do
post post
|> cast( |> cast(

View File

@@ -0,0 +1,40 @@
defmodule BDS.Posts.PostMedia do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string
@type t :: %__MODULE__{
id: String.t() | nil,
project_id: String.t() | nil,
post_id: String.t() | nil,
media_id: String.t() | nil,
post: term(),
media: term(),
sort_order: integer() | nil,
created_at: integer() | nil
}
schema "post_media" do
field :project_id, :string
belongs_to :post, BDS.Posts.Post, foreign_key: :post_id, references: :id, type: :string
belongs_to :media, BDS.Media.Media, foreign_key: :media_id, references: :id, type: :string
field :sort_order, :integer, default: 0
field :created_at, :integer
end
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(post_media, attrs) do
post_media
|> cast(attrs, [:id, :project_id, :post_id, :media_id, :sort_order, :created_at])
|> validate_required([:id, :project_id, :post_id, :media_id, :sort_order, :created_at])
|> foreign_key_constraint(:post_id)
|> foreign_key_constraint(:media_id)
|> unique_constraint([:post_id, :media_id], name: :post_media_post_media_idx)
end
end

View File

@@ -8,6 +8,25 @@ defmodule BDS.Posts.Translation do
@foreign_key_type :string @foreign_key_type :string
@statuses [:draft, :published] @statuses [:draft, :published]
@type status :: :draft | :published
@type t :: %__MODULE__{
id: String.t() | nil,
translation_for: String.t() | nil,
post: term(),
project_id: String.t() | nil,
language: String.t() | nil,
title: String.t() | nil,
excerpt: String.t() | nil,
content: String.t() | nil,
status: status(),
created_at: integer() | nil,
updated_at: integer() | nil,
published_at: integer() | nil,
file_path: String.t(),
checksum: String.t() | nil
}
schema "post_translations" do schema "post_translations" do
belongs_to :post, BDS.Posts.Post, belongs_to :post, BDS.Posts.Post,
foreign_key: :translation_for, foreign_key: :translation_for,
@@ -27,6 +46,7 @@ defmodule BDS.Posts.Translation do
field :checksum, :string field :checksum, :string
end end
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(translation, attrs) do def changeset(translation, attrs) do
translation translation
|> cast( |> cast(

View File

@@ -13,14 +13,35 @@ defmodule BDS.Projects do
@default_project_id "default" @default_project_id "default"
@default_project_name "My Blog" @default_project_name "My Blog"
@typedoc "An attribute map that may use atom or string keys."
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
@typedoc "Summary map returned for the shell projects panel."
@type project_summary :: %{
id: String.t(),
name: String.t(),
slug: String.t(),
data_path: String.t() | nil,
is_active: boolean()
}
@typedoc "Snapshot returned to the desktop shell."
@type shell_snapshot :: %{
active_project_id: String.t() | nil,
projects: [project_summary()]
}
@spec list_projects() :: [Project.t()]
def list_projects do def list_projects do
Repo.all(from project in Project, order_by: [asc: project.created_at]) Repo.all(from project in Project, order_by: [asc: project.created_at])
end end
@spec get_active_project() :: Project.t() | nil
def get_active_project do def get_active_project do
Repo.one(from project in Project, where: project.is_active == true, limit: 1) Repo.one(from project in Project, where: project.is_active == true, limit: 1)
end end
@spec shell_snapshot() :: shell_snapshot()
def shell_snapshot do def shell_snapshot do
_ = ensure_default_project() _ = ensure_default_project()
projects = list_projects() projects = list_projects()
@@ -32,9 +53,13 @@ defmodule BDS.Projects do
} }
end end
@spec get_project(String.t()) :: Project.t() | nil
def get_project(id), do: Repo.get(Project, id) def get_project(id), do: Repo.get(Project, id)
@spec get_project!(String.t()) :: Project.t()
def get_project!(id), do: Repo.get!(Project, id) def get_project!(id), do: Repo.get!(Project, id)
@spec ensure_default_project() :: {:ok, Project.t()} | {:error, term()}
def ensure_default_project do def ensure_default_project do
case Repo.get(Project, @default_project_id) do case Repo.get(Project, @default_project_id) do
%Project{} = project -> %Project{} = project ->
@@ -69,16 +94,19 @@ defmodule BDS.Projects do
end end
end end
@spec project_data_dir(Project.t()) :: String.t()
def project_data_dir(%Project{} = project) do def project_data_dir(%Project{} = project) do
project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__) project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__)
end end
@spec project_cache_dir(Project.t() | String.t()) :: String.t()
def project_cache_dir(%Project{} = project), do: project_cache_dir(project.id) def project_cache_dir(%Project{} = project), do: project_cache_dir(project.id)
def project_cache_dir(project_id) when is_binary(project_id) do def project_cache_dir(project_id) when is_binary(project_id) do
Path.join([project_cache_root(), "projects", project_id]) Path.join([project_cache_root(), "projects", project_id])
end end
@spec create_project(attrs()) :: {:ok, Project.t()} | {:error, term()}
def create_project(attrs) do def create_project(attrs) do
now = Persistence.now_ms() now = Persistence.now_ms()
name = attr(attrs, :name) || "" name = attr(attrs, :name) || ""
@@ -108,6 +136,7 @@ defmodule BDS.Projects do
end end
end end
@spec set_active_project(String.t()) :: {:ok, Project.t()} | {:error, :not_found | term()}
def set_active_project(project_id) do def set_active_project(project_id) do
case Repo.get(Project, project_id) do case Repo.get(Project, project_id) do
nil -> nil ->
@@ -133,6 +162,9 @@ defmodule BDS.Projects do
end end
end end
@spec delete_project(String.t()) ::
{:ok, Project.t()}
| {:error, :not_found | :cannot_delete_default_project | :cannot_delete_active_project | term()}
def delete_project(project_id) when is_binary(project_id) do def delete_project(project_id) when is_binary(project_id) do
case Repo.get(Project, project_id) do case Repo.get(Project, project_id) do
nil -> nil ->

View File

@@ -7,6 +7,18 @@ defmodule BDS.Projects.Project do
@primary_key {:id, :string, autogenerate: false} @primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string @foreign_key_type :string
@type t :: %__MODULE__{
id: String.t() | nil,
name: String.t() | nil,
slug: String.t() | nil,
description: String.t() | nil,
data_path: String.t() | nil,
created_at: integer() | nil,
updated_at: integer() | nil,
is_active: boolean(),
posts: term()
}
schema "projects" do schema "projects" do
field :name, :string field :name, :string
field :slug, :string field :slug, :string
@@ -19,6 +31,7 @@ defmodule BDS.Projects.Project do
has_many :posts, BDS.Posts.Post, foreign_key: :project_id has_many :posts, BDS.Posts.Post, foreign_key: :project_id
end end
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(project, attrs) do def changeset(project, attrs) do
project project
|> cast( |> cast(

View File

@@ -9,10 +9,15 @@ defmodule BDS.Publishing do
alias BDS.Repo alias BDS.Repo
alias BDS.Tasks alias BDS.Tasks
@typedoc "Credentials map for an upload destination."
@type credentials :: map()
@spec start_link(term()) :: GenServer.on_start()
def start_link(_opts) do def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__) GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end end
@spec upload_site(String.t(), credentials(), keyword()) :: {:ok, String.t()} | {:error, term()}
def upload_site(project_id, credentials, opts \\ []) def upload_site(project_id, credentials, opts \\ [])
when is_binary(project_id) and is_map(credentials) and is_list(opts) do when is_binary(project_id) and is_map(credentials) and is_list(opts) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
@@ -21,6 +26,7 @@ defmodule BDS.Publishing do
GenServer.call(__MODULE__, {:upload_site, project_id, normalized_credentials, targets, opts}) GenServer.call(__MODULE__, {:upload_site, project_id, normalized_credentials, targets, opts})
end end
@spec get_job(String.t()) :: PublishJob.t() | nil
def get_job(job_id) when is_binary(job_id) do def get_job(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:get_job, job_id}) GenServer.call(__MODULE__, {:get_job, job_id})
end end

View File

@@ -7,6 +7,24 @@ defmodule BDS.Publishing.PublishJob do
@primary_key {:id, :string, autogenerate: false} @primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string @foreign_key_type :string
@type ssh_mode :: :scp | :rsync
@type status :: :pending | :running | :completed | :failed
@type t :: %__MODULE__{
id: String.t() | nil,
project_id: String.t() | nil,
ssh_host: String.t() | nil,
ssh_user: String.t() | nil,
ssh_remote_path: String.t() | nil,
ssh_mode: ssh_mode(),
status: status(),
task_id: String.t() | nil,
targets: [String.t()],
error: String.t() | nil,
inserted_at: integer() | nil,
updated_at: integer() | nil
}
schema "publish_jobs" do schema "publish_jobs" do
field :project_id, :string field :project_id, :string
field :ssh_host, :string field :ssh_host, :string
@@ -21,6 +39,7 @@ defmodule BDS.Publishing.PublishJob do
field :updated_at, :integer field :updated_at, :integer
end end
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(job, attrs) do def changeset(job, attrs) do
job job
|> cast( |> cast(

View File

@@ -62,10 +62,18 @@ defmodule BDS.Search do
{"it", ~w(il lo la gli le un una uno e che per con ogni mattina)} {"it", ~w(il lo la gli le un una uno e che per con ogni mattina)}
] ]
@typedoc "Filters and pagination accepted by the search functions."
@type search_filters :: %{optional(atom()) => term(), optional(String.t()) => term()}
@typedoc "Reindex/long-running progress options."
@type reindex_opts :: keyword()
@spec list_stemmer_languages() :: [String.t()]
def list_stemmer_languages do def list_stemmer_languages do
@stemmer_languages @stemmer_languages
end end
@spec detect_language(String.t() | nil) :: String.t()
def detect_language(text) do def detect_language(text) do
normalized_text = text |> to_string() |> String.downcase() normalized_text = text |> to_string() |> String.downcase()
@@ -78,6 +86,7 @@ defmodule BDS.Search do
end end
end end
@spec stem(String.t() | nil, String.t() | nil) :: String.t()
def stem(text, language \\ nil) do def stem(text, language \\ nil) do
language = normalize_language(language || detect_language(text)) language = normalize_language(language || detect_language(text))
@@ -86,6 +95,14 @@ defmodule BDS.Search do
|> Enum.map_join(" ", &stem_token(&1, language)) |> Enum.map_join(" ", &stem_token(&1, language))
end end
@spec search_posts(String.t(), String.t() | nil, search_filters()) ::
{:ok,
%{
posts: [Post.t()],
total: non_neg_integer(),
offset: non_neg_integer(),
limit: non_neg_integer()
}}
def search_posts(project_id, query, filters \\ %{}) do def search_posts(project_id, query, filters \\ %{}) do
filters = normalize_filters(filters) filters = normalize_filters(filters)
@@ -104,6 +121,14 @@ defmodule BDS.Search do
}} }}
end end
@spec search_media(String.t(), String.t() | nil, search_filters()) ::
{:ok,
%{
media: [Media.t()],
total: non_neg_integer(),
offset: non_neg_integer(),
limit: non_neg_integer()
}}
def search_media(project_id, query, filters \\ %{}) do def search_media(project_id, query, filters \\ %{}) do
filters = normalize_filters(filters) filters = normalize_filters(filters)
@@ -121,6 +146,7 @@ defmodule BDS.Search do
}} }}
end end
@spec reindex_project(String.t()) :: :ok
def reindex_project(project_id) do def reindex_project(project_id) do
:ok = reindex_posts(project_id) :ok = reindex_posts(project_id)
:ok = reindex_media(project_id) :ok = reindex_media(project_id)
@@ -128,6 +154,7 @@ defmodule BDS.Search do
:ok :ok
end end
@spec reindex_posts(String.t(), reindex_opts()) :: :ok
def reindex_posts(project_id, opts \\ []) do def reindex_posts(project_id, opts \\ []) do
Repo.query!( Repo.query!(
"DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)", "DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)",
@@ -150,6 +177,7 @@ defmodule BDS.Search do
:ok :ok
end end
@spec reindex_media(String.t(), reindex_opts()) :: :ok
def reindex_media(project_id, opts \\ []) do def reindex_media(project_id, opts \\ []) do
Repo.query!( Repo.query!(
"DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)", "DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)",
@@ -172,6 +200,7 @@ defmodule BDS.Search do
:ok :ok
end end
@spec sync_post(Post.t() | String.t()) :: :ok
def sync_post(%Post{} = post) do def sync_post(%Post{} = post) do
delete_post(post.id) delete_post(post.id)
insert_post_index(post) insert_post_index(post)
@@ -185,6 +214,7 @@ defmodule BDS.Search do
end end
end end
@spec delete_post(Post.t() | String.t()) :: :ok
def delete_post(%Post{id: post_id}), do: delete_post(post_id) def delete_post(%Post{id: post_id}), do: delete_post(post_id)
def delete_post(post_id) when is_binary(post_id) do def delete_post(post_id) when is_binary(post_id) do
@@ -192,6 +222,7 @@ defmodule BDS.Search do
:ok :ok
end end
@spec sync_media(Media.t() | String.t()) :: :ok
def sync_media(%Media{} = media) do def sync_media(%Media{} = media) do
delete_media(media.id) delete_media(media.id)
insert_media_index(media) insert_media_index(media)
@@ -205,6 +236,7 @@ defmodule BDS.Search do
end end
end end
@spec delete_media(Media.t() | String.t()) :: :ok
def delete_media(%Media{id: media_id}), do: delete_media(media_id) def delete_media(%Media{id: media_id}), do: delete_media(media_id)
def delete_media(media_id) when is_binary(media_id) do def delete_media(media_id) when is_binary(media_id) do

216
lib/bds/wxr_parser.ex Normal file
View File

@@ -0,0 +1,216 @@
defmodule BDS.WxrParser do
@moduledoc false
require Record
Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl"))
Record.defrecord(:xmlAttribute, Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl"))
Record.defrecord(:xmlText, Record.extract(:xmlText, from_lib: "xmerl/include/xmerl.hrl"))
def parse_file(file_path) when is_binary(file_path) do
file_path
|> File.read!()
|> parse_xml()
end
def parse_xml(xml_content) when is_binary(xml_content) do
{document, _rest} = :xmerl_scan.string(String.to_charlist(xml_content))
case :xmerl_xpath.string(~c"/rss/channel", document) do
[channel] ->
%{
site: parse_site(channel),
posts: parse_post_like_items(channel),
pages: parse_items(channel, "page"),
media: parse_media(channel),
categories: parse_categories(channel),
tags: parse_tags(channel)
}
_other ->
raise RuntimeError, "Invalid WXR file: no <channel> element found"
end
end
defp parse_site(channel) do
%{
title: child_text(channel, "title"),
link: child_text(channel, "link"),
description: child_text(channel, "description"),
language: child_text(channel, "language")
}
end
defp parse_categories(channel) do
channel
|> direct_children()
|> Enum.filter(&(full_name(&1) == "wp:category"))
|> Enum.map(fn element ->
%{
name: child_text(element, "cat_name"),
slug: child_text(element, "category_nicename"),
parent: child_text(element, "category_parent")
}
end)
end
defp parse_tags(channel) do
channel
|> direct_children()
|> Enum.filter(&(full_name(&1) == "wp:tag"))
|> Enum.map(fn element ->
%{
name: child_text(element, "tag_name"),
slug: child_text(element, "tag_slug")
}
end)
end
defp parse_items(channel, expected_type) do
channel
|> direct_children_named("item")
|> Enum.filter(&(child_text(&1, "post_type") == expected_type))
|> Enum.map(&parse_post_item/1)
end
defp parse_post_like_items(channel) do
channel
|> direct_children_named("item")
|> Enum.filter(fn item ->
type = child_text(item, "post_type")
type not in ["", "attachment", "page"]
end)
|> Enum.map(&parse_post_item/1)
end
defp parse_media(channel) do
channel
|> direct_children_named("item")
|> Enum.filter(&(child_text(&1, "post_type") == "attachment"))
|> Enum.map(&parse_media_item/1)
end
defp parse_post_item(item) do
%{
wp_id: parse_integer(child_text(item, "post_id")),
title: child_text(item, "title"),
slug: child_text(item, "post_name"),
content: child_text_by_full_name(item, "content:encoded"),
excerpt: child_text_by_full_name(item, "excerpt:encoded"),
pub_date: blank_to_nil(child_text(item, "pubDate")),
post_date: blank_to_nil(child_text(item, "post_date")),
post_modified: blank_to_nil(child_text(item, "post_modified")),
creator: child_text_by_full_name(item, "dc:creator"),
status: child_text(item, "status"),
post_type: child_text(item, "post_type"),
categories: item_taxonomy(item, "category"),
tags: item_taxonomy(item, "post_tag")
}
end
defp parse_media_item(item) do
attachment_url = child_text(item, "attachment_url")
filename = attachment_url |> Path.basename() |> blank_to_nil() || ""
%{
wp_id: parse_integer(child_text(item, "post_id")),
title: child_text(item, "title"),
url: attachment_url,
filename: filename,
relative_path: relative_upload_path(attachment_url),
pub_date: blank_to_nil(child_text(item, "pubDate")),
parent_id: parse_integer(child_text(item, "post_parent")),
mime_type: MIME.from_path(filename),
description: child_text_by_full_name(item, "content:encoded")
}
end
defp item_taxonomy(item, domain) do
item
|> direct_children_named("category")
|> Enum.filter(&(xml_attr(&1, :domain) == domain))
|> Enum.map(&text_content/1)
|> Enum.reject(&(&1 == ""))
end
defp relative_upload_path(url) when is_binary(url) do
marker = "/wp-content/uploads/"
case String.split(url, marker, parts: 2) do
[_prefix, suffix] -> suffix
_other -> Path.basename(url)
end
end
defp direct_children(element) do
Enum.filter(xmlElement(element, :content), fn child ->
is_tuple(child) and tuple_size(child) > 0 and elem(child, 0) == :xmlElement
end)
end
defp direct_children_named(element, name) do
Enum.filter(direct_children(element), &(local_name(&1) == name))
end
defp child_text(element, name) do
element
|> direct_children_named(name)
|> List.first()
|> text_content()
end
defp child_text_by_full_name(element, name) do
element
|> direct_children()
|> Enum.find(&(full_name(&1) == name))
|> text_content()
end
defp text_content(nil), do: ""
defp text_content(element) do
element
|> xmlElement(:content)
|> Enum.map_join("", fn
child when is_tuple(child) and tuple_size(child) > 0 and elem(child, 0) == :xmlText ->
child
|> xmlText(:value)
|> to_string()
child when is_tuple(child) and tuple_size(child) > 0 and elem(child, 0) == :xmlElement ->
text_content(child)
_other -> ""
end)
|> String.trim()
end
defp xml_attr(element, name) do
element
|> xmlElement(:attributes)
|> Enum.find_value(fn attribute ->
if xmlAttribute(attribute, :name) == name do
attribute |> xmlAttribute(:value) |> to_string()
end
end)
end
defp full_name(element), do: element |> xmlElement(:name) |> to_string()
defp local_name(element) do
element
|> full_name()
|> String.split(":")
|> List.last()
end
defp parse_integer(value) do
case Integer.parse(to_string(value)) do
{parsed, _rest} -> parsed
:error -> 0
end
end
defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value
end

View File

@@ -64,6 +64,35 @@
"translationValidation.revalidate": "Erneut validieren", "translationValidation.revalidate": "Erneut validieren",
"translationValidation.fix": "Probleme beheben", "translationValidation.fix": "Probleme beheben",
"translationValidation.toast.fixSuccess": "%{dbRows} DB-Zeilen und %{files} Dateien gelöscht, %{flushed} Übersetzungen auf Datenträger geschrieben", "translationValidation.toast.fixSuccess": "%{dbRows} DB-Zeilen und %{files} Dateien gelöscht, %{flushed} Übersetzungen auf Datenträger geschrieben",
"menuEditor.tabTitle": "Blog-Menü",
"menuEditor.title": "Blog-Menü-Editor",
"menuEditor.description": "Verwalte die zentrale Blog-Navigationsstruktur und speichere sie in meta/menu.opml.",
"menuEditor.save": "Menü speichern",
"menuEditor.saved": "Blog-Menü gespeichert",
"menuEditor.addEntry": "Eintrag hinzufügen",
"menuEditor.addCategoryArchive": "Kategorie-Archiv hinzufügen",
"menuEditor.addCategoryArchiveShort": "K+",
"menuEditor.addSubmenu": "Untermenü hinzufügen",
"menuEditor.moveUp": "Nach oben",
"menuEditor.moveDown": "Nach unten",
"menuEditor.indent": "Einrücken",
"menuEditor.unindent": "Ausrücken",
"menuEditor.delete": "Löschen",
"menuEditor.newEntryPlaceholder": "Seitentitel oder Untermenü-Bezeichnung eingeben",
"menuEditor.newCategoryPlaceholder": "Kategorienamen eingeben",
"menuEditor.createHint": "Wähle unten eine Seite aus oder drücke Enter, um ein Untermenü zu erstellen",
"menuEditor.dragHandle": "Menüeintrag ziehen",
"menuEditor.empty": "Noch keine Menüeinträge. Füge eine Seite oder ein Untermenü hinzu, um zu beginnen.",
"menuEditor.newPage": "Neue Seite",
"menuEditor.newSubmenu": "Neues Untermenü",
"menuEditor.pagePicker.title": "Seite auswählen",
"menuEditor.pagePicker.empty": "Keine passenden Seiten gefunden.",
"menuEditor.categoryPicker.hint": "Wähle eine vorhandene Kategorie oder drücke Enter, um einen neuen Archiv-Eintrag zu erstellen",
"menuEditor.categoryPicker.empty": "Keine passenden Kategorien gefunden.",
"menuEditor.type.page": "Seite",
"menuEditor.type.home": "Startseite",
"menuEditor.type.submenu": "Untermenü",
"menuEditor.type.categoryArchive": "Kategorie-Archiv",
"chat.newChat": "Neuer Chat", "chat.newChat": "Neuer Chat",
"chat.setupTitle": "KI-Chat-Einrichtung", "chat.setupTitle": "KI-Chat-Einrichtung",
"chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich", "chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich",
@@ -97,6 +126,115 @@
"sidebar.newPost": "Neuer Beitrag", "sidebar.newPost": "Neuer Beitrag",
"sidebar.importMedia": "Medien importieren", "sidebar.importMedia": "Medien importieren",
"sidebar.import.newDefinition": "Neue Importdefinition", "sidebar.import.newDefinition": "Neue Importdefinition",
"importAnalysis.loadingDefinition": "Importdefinition wird geladen...",
"importAnalysis.namePlaceholder": "Importname...",
"importAnalysis.headerDescription": "Wähle eine WordPress-Exportdatei (WXR) und einen Upload-Ordner, um den Import zu analysieren.",
"importAnalysis.uploadsFolder": "Uploads-Ordner",
"importAnalysis.noFolderSelected": "Kein Ordner ausgewählt",
"importAnalysis.wxrFile": "WXR-Datei",
"importAnalysis.selectFileToAnalyze": "Datei zur Analyse auswählen",
"importAnalysis.analyzing": "Analysiere...",
"importAnalysis.selectAndAnalyze": "Auswählen & analysieren",
"importAnalysis.analyzingWxr": "WXR-Datei wird analysiert...",
"importAnalysis.emptyState": "Wähle eine WordPress-Exportdatei, um die Analyse zu starten.",
"importAnalysis.importing": "Import läuft...",
"importAnalysis.importComplete": "Import erfolgreich abgeschlossen!",
"importAnalysis.importFailed": "Import fehlgeschlagen: %{error}",
"importAnalysis.untitledImport": "Unbenannter Import",
"importAnalysis.executionStarting": "Starte...",
"importAnalysis.unknownError": "Unbekannter Fehler",
"importAnalysis.readyToImport": "Bereit zum Import:",
"importAnalysis.tagsCategories": "Tags/Kategorien",
"importAnalysis.posts": "Beiträge",
"importAnalysis.media": "Medien",
"importAnalysis.pages": "Seiten",
"importAnalysis.nothingToImport": "Nichts zu importieren",
"importAnalysis.importItems": "%{count} Elemente importieren",
"importAnalysis.postSlugConflicts": "Beitrags-Slug-Konflikte",
"importAnalysis.pageSlugConflicts": "Seiten-Slug-Konflikte",
"importAnalysis.postsWithCount": "Beiträge (%{count})",
"importAnalysis.otherWithCount": "Andere (%{count})",
"importAnalysis.pagesWithCount": "Seiten (%{count})",
"importAnalysis.mediaWithCount": "Medien (%{count})",
"importAnalysis.site": "Website",
"importAnalysis.untitled": "Ohne Titel",
"importAnalysis.url": "URL",
"importAnalysis.language": "Sprache",
"importAnalysis.file": "Datei",
"importAnalysis.notAvailable": "k. A.",
"importAnalysis.new": "neu",
"importAnalysis.update": "Aktualisierung",
"importAnalysis.conflict": "Konflikt",
"importAnalysis.duplicate": "Duplikat",
"importAnalysis.missing": "fehlend",
"importAnalysis.categories": "Kategorien",
"importAnalysis.existing": "vorhanden",
"importAnalysis.mapped": "zugeordnet",
"importAnalysis.tags": "Tags",
"importAnalysis.dateDistribution": "Datumsverteilung",
"importAnalysis.postsPages": "Beiträge/Seiten",
"importAnalysis.total": "gesamt",
"importAnalysis.wordpressId": "WordPress-ID",
"importAnalysis.type": "Typ",
"importAnalysis.author": "Autor",
"importAnalysis.unknown": "Unbekannt",
"importAnalysis.published": "Veröffentlicht",
"importAnalysis.excerpt": "Auszug",
"importAnalysis.content": "Inhalt",
"importAnalysis.loading": "Lade...",
"importAnalysis.mimeType": "MIME-Typ",
"importAnalysis.uploaded": "Hochgeladen",
"importAnalysis.parentPostId": "Elternbeitrags-ID",
"importAnalysis.description": "Beschreibung",
"importAnalysis.slug": "Slug",
"importAnalysis.newEntryWxr": "Neuer Eintrag (WXR)",
"importAnalysis.existingEntry": "Vorhandener Eintrag",
"importAnalysis.resolution": "Lösung",
"importAnalysis.ignore": "Ignorieren",
"importAnalysis.overwrite": "Überschreiben",
"importAnalysis.importNewSlug": "Importieren (neuer Slug)",
"importAnalysis.status": "Status",
"importAnalysis.title": "Titel",
"importAnalysis.wpStatus": "WP-Status",
"importAnalysis.existingMatch": "Vorhandene Übereinstimmung",
"importAnalysis.none": "--",
"importAnalysis.filename": "Dateiname",
"importAnalysis.path": "Pfad",
"importAnalysis.taxonomyTitle": "Kategorien & Tags",
"importAnalysis.mappedCount": "%{count} zugeordnet",
"importAnalysis.analyzeWith": "Analysieren mit...",
"importAnalysis.aiMappingHint": "KI schlägt Zuordnungen von neuen zu vorhandenen Einträgen vor, um Duplikate zu vermeiden",
"importAnalysis.mapToPlaceholder": "Zuordnen zu...",
"importAnalysis.mappingTooltip": "Klicken, um Zuordnung zu %{action}",
"importAnalysis.mappingActionEdit": "bearbeiten",
"importAnalysis.mappingActionAdd": "hinzuzufügen",
"importAnalysis.clearMapping": "Zuordnung entfernen",
"importAnalysis.macrosWithCount": "Makros (%{count})",
"importAnalysis.unmappedCount": "%{count} nicht zugeordnet",
"importAnalysis.macroStatusMapped": "Zugeordnet",
"importAnalysis.macroStatusUnknown": "Unbekannt",
"importAnalysis.macroUses": "%{count} Verwendungen",
"importAnalysis.usedIn": "Verwendet in: %{items}%{more}",
"importAnalysis.moreSuffix": ", +%{count} weitere",
"importAnalysis.noParameters": "(keine Parameter)",
"importAnalysis.other": "Sonstige",
"importAnalysis.otherDescription": "Einträge, die weder Beiträge noch Seiten sind (Revisionen, Navigationsmenüs usw.) werden nicht importiert.",
"importAnalysis.browse": "Durchsuchen...",
"importAnalysis.eta": "Verbleibend: %{value}",
"importAnalysis.etaSeconds": "%{count}s",
"importAnalysis.etaMinutes": "%{minutes}m %{seconds}s",
"importAnalysis.phase.tags": "Tags & Kategorien werden importiert...",
"importAnalysis.phase.posts": "Beiträge werden importiert...",
"importAnalysis.phase.media": "Medien werden importiert...",
"importAnalysis.phase.pages": "Seiten werden importiert...",
"importAnalysis.phase.complete": "Import abgeschlossen",
"importAnalysis.analysisPhase.parsing": "WXR-Datei wird gelesen...",
"importAnalysis.analysisPhase.scanning": "Einträge werden gescannt...",
"importAnalysis.analysisPhase.taxonomies": "Taxonomien werden analysiert...",
"importAnalysis.analysisPhase.posts": "Beiträge werden analysiert...",
"importAnalysis.analysisPhase.media": "Medien werden analysiert...",
"importAnalysis.analysisPhase.complete": "Analyse abgeschlossen",
"importAnalysis.contentDuplicate": "Duplikat",
"sidebar.scripts.newScript": "Neues Skript", "sidebar.scripts.newScript": "Neues Skript",
"sidebar.templates.newTemplate": "Neue Vorlage", "sidebar.templates.newTemplate": "Neue Vorlage",
"sidebar.results": "%{count} Ergebnisse", "sidebar.results": "%{count} Ergebnisse",

View File

@@ -64,6 +64,35 @@
"translationValidation.revalidate": "Revalidate", "translationValidation.revalidate": "Revalidate",
"translationValidation.fix": "Fix Issues", "translationValidation.fix": "Fix Issues",
"translationValidation.toast.fixSuccess": "Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk", "translationValidation.toast.fixSuccess": "Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk",
"menuEditor.tabTitle": "Blog Menu",
"menuEditor.title": "Blog Menu Editor",
"menuEditor.description": "Manage the central blog navigation outline and save it to meta/menu.opml.",
"menuEditor.save": "Save Menu",
"menuEditor.saved": "Blog menu saved",
"menuEditor.addEntry": "Add Entry",
"menuEditor.addCategoryArchive": "Add Category Archive",
"menuEditor.addCategoryArchiveShort": "C+",
"menuEditor.addSubmenu": "Add Submenu",
"menuEditor.moveUp": "Move Up",
"menuEditor.moveDown": "Move Down",
"menuEditor.indent": "Indent",
"menuEditor.unindent": "Unindent",
"menuEditor.delete": "Delete",
"menuEditor.newEntryPlaceholder": "Type a page title or submenu label",
"menuEditor.newCategoryPlaceholder": "Type a category name",
"menuEditor.createHint": "Select a page below or press Enter to create a submenu",
"menuEditor.dragHandle": "Drag menu item",
"menuEditor.empty": "No menu entries yet. Add a page or submenu to start.",
"menuEditor.newPage": "New Page",
"menuEditor.newSubmenu": "New Submenu",
"menuEditor.pagePicker.title": "Select Page",
"menuEditor.pagePicker.empty": "No matching pages found.",
"menuEditor.categoryPicker.hint": "Select an existing category or press Enter to create a new archive entry",
"menuEditor.categoryPicker.empty": "No matching categories found.",
"menuEditor.type.page": "Page",
"menuEditor.type.home": "Home",
"menuEditor.type.submenu": "Submenu",
"menuEditor.type.categoryArchive": "Category Archive",
"chat.newChat": "New Chat", "chat.newChat": "New Chat",
"chat.setupTitle": "AI Chat Setup", "chat.setupTitle": "AI Chat Setup",
"chat.apiKeyRequiredTitle": "API Key Required", "chat.apiKeyRequiredTitle": "API Key Required",
@@ -97,6 +126,115 @@
"sidebar.newPost": "New Post", "sidebar.newPost": "New Post",
"sidebar.importMedia": "Import media", "sidebar.importMedia": "Import media",
"sidebar.import.newDefinition": "New Import Definition", "sidebar.import.newDefinition": "New Import Definition",
"importAnalysis.loadingDefinition": "Loading import definition...",
"importAnalysis.namePlaceholder": "Import name...",
"importAnalysis.headerDescription": "Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.",
"importAnalysis.uploadsFolder": "Uploads Folder",
"importAnalysis.noFolderSelected": "No folder selected",
"importAnalysis.wxrFile": "WXR File",
"importAnalysis.selectFileToAnalyze": "Select a file to analyze",
"importAnalysis.analyzing": "Analyzing...",
"importAnalysis.selectAndAnalyze": "Select & Analyze",
"importAnalysis.analyzingWxr": "Analyzing WXR file...",
"importAnalysis.emptyState": "Select a WordPress export file to begin analysis.",
"importAnalysis.importing": "Importing...",
"importAnalysis.importComplete": "Import completed successfully!",
"importAnalysis.importFailed": "Import failed: %{error}",
"importAnalysis.untitledImport": "Untitled Import",
"importAnalysis.executionStarting": "Starting...",
"importAnalysis.unknownError": "Unknown error",
"importAnalysis.readyToImport": "Ready to import:",
"importAnalysis.tagsCategories": "tags/categories",
"importAnalysis.posts": "posts",
"importAnalysis.media": "media",
"importAnalysis.pages": "pages",
"importAnalysis.nothingToImport": "Nothing to Import",
"importAnalysis.importItems": "Import %{count} Items",
"importAnalysis.postSlugConflicts": "Post Slug Conflicts",
"importAnalysis.pageSlugConflicts": "Page Slug Conflicts",
"importAnalysis.postsWithCount": "Posts (%{count})",
"importAnalysis.otherWithCount": "Other (%{count})",
"importAnalysis.pagesWithCount": "Pages (%{count})",
"importAnalysis.mediaWithCount": "Media (%{count})",
"importAnalysis.site": "Site",
"importAnalysis.untitled": "Untitled",
"importAnalysis.url": "URL",
"importAnalysis.language": "Language",
"importAnalysis.file": "File",
"importAnalysis.notAvailable": "N/A",
"importAnalysis.new": "new",
"importAnalysis.update": "update",
"importAnalysis.conflict": "conflict",
"importAnalysis.duplicate": "duplicate",
"importAnalysis.missing": "missing",
"importAnalysis.categories": "Categories",
"importAnalysis.existing": "existing",
"importAnalysis.mapped": "mapped",
"importAnalysis.tags": "Tags",
"importAnalysis.dateDistribution": "Date Distribution",
"importAnalysis.postsPages": "Posts/Pages",
"importAnalysis.total": "total",
"importAnalysis.wordpressId": "WordPress ID",
"importAnalysis.type": "Type",
"importAnalysis.author": "Author",
"importAnalysis.unknown": "Unknown",
"importAnalysis.published": "Published",
"importAnalysis.excerpt": "Excerpt",
"importAnalysis.content": "Content",
"importAnalysis.loading": "Loading...",
"importAnalysis.mimeType": "MIME Type",
"importAnalysis.uploaded": "Uploaded",
"importAnalysis.parentPostId": "Parent Post ID",
"importAnalysis.description": "Description",
"importAnalysis.slug": "Slug",
"importAnalysis.newEntryWxr": "New Entry (WXR)",
"importAnalysis.existingEntry": "Existing Entry",
"importAnalysis.resolution": "Resolution",
"importAnalysis.ignore": "Ignore",
"importAnalysis.overwrite": "Overwrite",
"importAnalysis.importNewSlug": "Import (new slug)",
"importAnalysis.status": "Status",
"importAnalysis.title": "Title",
"importAnalysis.wpStatus": "WP Status",
"importAnalysis.existingMatch": "Existing Match",
"importAnalysis.none": "--",
"importAnalysis.filename": "Filename",
"importAnalysis.path": "Path",
"importAnalysis.taxonomyTitle": "Categories & Tags",
"importAnalysis.mappedCount": "%{count} mapped",
"importAnalysis.analyzeWith": "Analyze with...",
"importAnalysis.aiMappingHint": "AI will suggest mappings from new to existing items to avoid duplicates",
"importAnalysis.mapToPlaceholder": "Map to...",
"importAnalysis.mappingTooltip": "Click to %{action} mapping",
"importAnalysis.mappingActionEdit": "edit",
"importAnalysis.mappingActionAdd": "add",
"importAnalysis.clearMapping": "Clear mapping",
"importAnalysis.macrosWithCount": "Macros (%{count})",
"importAnalysis.unmappedCount": "%{count} unmapped",
"importAnalysis.macroStatusMapped": "Mapped",
"importAnalysis.macroStatusUnknown": "Unknown",
"importAnalysis.macroUses": "%{count} uses",
"importAnalysis.usedIn": "Used in: %{items}%{more}",
"importAnalysis.moreSuffix": ", +%{count} more",
"importAnalysis.noParameters": "(no parameters)",
"importAnalysis.other": "Other",
"importAnalysis.otherDescription": "Items that are not posts or pages (revisions, navigation menus, etc.) — not imported.",
"importAnalysis.browse": "Browse...",
"importAnalysis.eta": "ETA: %{value}",
"importAnalysis.etaSeconds": "%{count}s",
"importAnalysis.etaMinutes": "%{minutes}m %{seconds}s",
"importAnalysis.phase.tags": "Importing tags & categories...",
"importAnalysis.phase.posts": "Importing posts...",
"importAnalysis.phase.media": "Importing media...",
"importAnalysis.phase.pages": "Importing pages...",
"importAnalysis.phase.complete": "Import complete",
"importAnalysis.analysisPhase.parsing": "Parsing WXR file...",
"importAnalysis.analysisPhase.scanning": "Scanning entries...",
"importAnalysis.analysisPhase.taxonomies": "Analyzing taxonomies...",
"importAnalysis.analysisPhase.posts": "Analyzing posts...",
"importAnalysis.analysisPhase.media": "Analyzing media...",
"importAnalysis.analysisPhase.complete": "Analysis complete",
"importAnalysis.contentDuplicate": "duplicate",
"sidebar.scripts.newScript": "New Script", "sidebar.scripts.newScript": "New Script",
"sidebar.templates.newTemplate": "New Template", "sidebar.templates.newTemplate": "New Template",
"sidebar.results": "%{count} results", "sidebar.results": "%{count} results",

View File

@@ -64,6 +64,35 @@
"translationValidation.revalidate": "Revalidar", "translationValidation.revalidate": "Revalidar",
"translationValidation.fix": "Corregir problemas", "translationValidation.fix": "Corregir problemas",
"translationValidation.toast.fixSuccess": "%{dbRows} filas de BD y %{files} archivos eliminados, %{flushed} traducciones escritas a disco", "translationValidation.toast.fixSuccess": "%{dbRows} filas de BD y %{files} archivos eliminados, %{flushed} traducciones escritas a disco",
"menuEditor.tabTitle": "Menú del blog",
"menuEditor.title": "Editor del menú del blog",
"menuEditor.description": "Gestiona la estructura central de navegación del blog y guárdala en meta/menu.opml.",
"menuEditor.save": "Guardar menú",
"menuEditor.saved": "Menú del blog guardado",
"menuEditor.addEntry": "Agregar entrada",
"menuEditor.addCategoryArchive": "Agregar archivo de categoría",
"menuEditor.addCategoryArchiveShort": "C+",
"menuEditor.addSubmenu": "Agregar submenú",
"menuEditor.moveUp": "Subir",
"menuEditor.moveDown": "Bajar",
"menuEditor.indent": "Indentar",
"menuEditor.unindent": "Desindentar",
"menuEditor.delete": "Eliminar",
"menuEditor.newEntryPlaceholder": "Escribe un título de página o una etiqueta de submenú",
"menuEditor.newCategoryPlaceholder": "Escribe un nombre de categoría",
"menuEditor.createHint": "Selecciona una página abajo o pulsa Enter para crear un submenú",
"menuEditor.dragHandle": "Arrastrar elemento del menú",
"menuEditor.empty": "Todavía no hay entradas de menú. Agrega una página o un submenú para empezar.",
"menuEditor.newPage": "Nueva página",
"menuEditor.newSubmenu": "Nuevo submenú",
"menuEditor.pagePicker.title": "Seleccionar página",
"menuEditor.pagePicker.empty": "No se encontraron páginas coincidentes.",
"menuEditor.categoryPicker.hint": "Selecciona una categoría existente o pulsa Enter para crear una nueva entrada de archivo",
"menuEditor.categoryPicker.empty": "No se encontraron categorías coincidentes.",
"menuEditor.type.page": "Página",
"menuEditor.type.home": "Inicio",
"menuEditor.type.submenu": "Submenú",
"menuEditor.type.categoryArchive": "Archivo de categoría",
"chat.newChat": "Nuevo chat", "chat.newChat": "Nuevo chat",
"chat.setupTitle": "Configuración de chat IA", "chat.setupTitle": "Configuración de chat IA",
"chat.apiKeyRequiredTitle": "Clave API requerida", "chat.apiKeyRequiredTitle": "Clave API requerida",
@@ -97,6 +126,115 @@
"sidebar.newPost": "Nueva entrada", "sidebar.newPost": "Nueva entrada",
"sidebar.importMedia": "Importar medios", "sidebar.importMedia": "Importar medios",
"sidebar.import.newDefinition": "Nueva definición", "sidebar.import.newDefinition": "Nueva definición",
"importAnalysis.loadingDefinition": "Cargando definición de importación…",
"importAnalysis.namePlaceholder": "Nombre de la definición de importación",
"importAnalysis.headerDescription": "Analiza un archivo WXR antes de importar.",
"importAnalysis.uploadsFolder": "Carpeta uploads",
"importAnalysis.noFolderSelected": "Ninguna carpeta seleccionada",
"importAnalysis.wxrFile": "Archivo WXR",
"importAnalysis.selectFileToAnalyze": "Selecciona un archivo para analizar",
"importAnalysis.analyzing": "Analizando…",
"importAnalysis.selectAndAnalyze": "Seleccionar y analizar",
"importAnalysis.analyzingWxr": "Analizando archivo WXR…",
"importAnalysis.emptyState": "Selecciona un archivo WXR e inicia el análisis.",
"importAnalysis.importing": "Importando…",
"importAnalysis.importComplete": "Importación completada: %{count}",
"importAnalysis.importFailed": "La importación falló: %{error}",
"importAnalysis.untitledImport": "Importación sin título",
"importAnalysis.executionStarting": "Iniciando...",
"importAnalysis.unknownError": "Error desconocido",
"importAnalysis.readyToImport": "Listo para importar:",
"importAnalysis.tagsCategories": "etiquetas/categorías",
"importAnalysis.posts": "publicaciones",
"importAnalysis.media": "medios",
"importAnalysis.pages": "páginas",
"importAnalysis.nothingToImport": "Nada para importar",
"importAnalysis.importItems": "Importar %{count} elementos",
"importAnalysis.postSlugConflicts": "Conflictos de slug de publicaciones",
"importAnalysis.pageSlugConflicts": "Conflictos de slug de páginas",
"importAnalysis.postsWithCount": "Publicaciones (%{count})",
"importAnalysis.otherWithCount": "Otros (%{count})",
"importAnalysis.pagesWithCount": "Páginas (%{count})",
"importAnalysis.mediaWithCount": "Medios (%{count})",
"importAnalysis.site": "Sitio",
"importAnalysis.untitled": "Sin título",
"importAnalysis.url": "URL",
"importAnalysis.language": "Idioma",
"importAnalysis.file": "Archivo",
"importAnalysis.notAvailable": "N/D",
"importAnalysis.new": "nuevo",
"importAnalysis.update": "actualización",
"importAnalysis.conflict": "conflicto",
"importAnalysis.duplicate": "duplicado",
"importAnalysis.missing": "faltante",
"importAnalysis.categories": "Categorías",
"importAnalysis.existing": "existente",
"importAnalysis.mapped": "mapeado",
"importAnalysis.tags": "Etiquetas",
"importAnalysis.dateDistribution": "Distribución por fecha",
"importAnalysis.postsPages": "Publicaciones/Páginas",
"importAnalysis.total": "total",
"importAnalysis.wordpressId": "ID de WordPress",
"importAnalysis.type": "Tipo",
"importAnalysis.author": "Autor",
"importAnalysis.unknown": "Desconocido",
"importAnalysis.published": "Publicado",
"importAnalysis.excerpt": "Extracto",
"importAnalysis.content": "Contenido",
"importAnalysis.loading": "Cargando...",
"importAnalysis.mimeType": "Tipo MIME",
"importAnalysis.uploaded": "Subido",
"importAnalysis.parentPostId": "ID de publicación padre",
"importAnalysis.description": "Descripción",
"importAnalysis.slug": "Slug",
"importAnalysis.newEntryWxr": "Nueva entrada (WXR)",
"importAnalysis.existingEntry": "Entrada existente",
"importAnalysis.resolution": "Resolución",
"importAnalysis.ignore": "Ignorar",
"importAnalysis.overwrite": "Sobrescribir",
"importAnalysis.importNewSlug": "Importar (nuevo slug)",
"importAnalysis.status": "Estado",
"importAnalysis.title": "Título",
"importAnalysis.wpStatus": "Estado WP",
"importAnalysis.existingMatch": "Coincidencia existente",
"importAnalysis.none": "--",
"importAnalysis.filename": "Nombre de archivo",
"importAnalysis.path": "Ruta",
"importAnalysis.taxonomyTitle": "Categorías y Etiquetas",
"importAnalysis.mappedCount": "%{count} mapeados",
"importAnalysis.analyzeWith": "Analizar con...",
"importAnalysis.aiMappingHint": "La IA sugerirá mapeos de elementos nuevos a existentes para evitar duplicados",
"importAnalysis.mapToPlaceholder": "Mapear a...",
"importAnalysis.mappingTooltip": "Haz clic para %{action} el mapeo",
"importAnalysis.mappingActionEdit": "editar",
"importAnalysis.mappingActionAdd": "agregar",
"importAnalysis.clearMapping": "Borrar mapeo",
"importAnalysis.macrosWithCount": "Macros (%{count})",
"importAnalysis.unmappedCount": "%{count} sin mapear",
"importAnalysis.macroStatusMapped": "Mapeado",
"importAnalysis.macroStatusUnknown": "Desconocido",
"importAnalysis.macroUses": "%{count} usos",
"importAnalysis.usedIn": "Usado en: %{items}%{more}",
"importAnalysis.moreSuffix": ", +%{count} más",
"importAnalysis.noParameters": "(sin parámetros)",
"importAnalysis.other": "Otros",
"importAnalysis.otherDescription": "Elementos que no son entradas ni páginas (revisiones, menús de navegación, etc.): no se importan.",
"importAnalysis.browse": "Examinar...",
"importAnalysis.eta": "Tiempo restante: %{value}",
"importAnalysis.etaSeconds": "%{count}s",
"importAnalysis.etaMinutes": "%{minutes}m %{seconds}s",
"importAnalysis.phase.tags": "Importando etiquetas y categorías...",
"importAnalysis.phase.posts": "Importando entradas...",
"importAnalysis.phase.media": "Importando medios...",
"importAnalysis.phase.pages": "Importando páginas...",
"importAnalysis.phase.complete": "Importación completada",
"importAnalysis.analysisPhase.parsing": "Analizando archivo WXR...",
"importAnalysis.analysisPhase.scanning": "Escaneando entradas...",
"importAnalysis.analysisPhase.taxonomies": "Analizando taxonomías...",
"importAnalysis.analysisPhase.posts": "Analizando entradas...",
"importAnalysis.analysisPhase.media": "Analizando medios...",
"importAnalysis.analysisPhase.complete": "Análisis completado",
"importAnalysis.contentDuplicate": "duplicado",
"sidebar.scripts.newScript": "Nuevo script", "sidebar.scripts.newScript": "Nuevo script",
"sidebar.templates.newTemplate": "Nueva plantilla", "sidebar.templates.newTemplate": "Nueva plantilla",
"sidebar.results": "%{count} resultados", "sidebar.results": "%{count} resultados",

View File

@@ -64,6 +64,35 @@
"translationValidation.revalidate": "Revalider", "translationValidation.revalidate": "Revalider",
"translationValidation.fix": "Corriger les problèmes", "translationValidation.fix": "Corriger les problèmes",
"translationValidation.toast.fixSuccess": "%{dbRows} lignes DB et %{files} fichiers supprimés, %{flushed} traductions écrites sur disque", "translationValidation.toast.fixSuccess": "%{dbRows} lignes DB et %{files} fichiers supprimés, %{flushed} traductions écrites sur disque",
"menuEditor.tabTitle": "Menu du blog",
"menuEditor.title": "Éditeur du menu du blog",
"menuEditor.description": "Gérez la structure centrale de navigation du blog et enregistrez-la dans meta/menu.opml.",
"menuEditor.save": "Enregistrer le menu",
"menuEditor.saved": "Menu du blog enregistré",
"menuEditor.addEntry": "Ajouter une entrée",
"menuEditor.addCategoryArchive": "Ajouter une archive de catégorie",
"menuEditor.addCategoryArchiveShort": "C+",
"menuEditor.addSubmenu": "Ajouter un sous-menu",
"menuEditor.moveUp": "Monter",
"menuEditor.moveDown": "Descendre",
"menuEditor.indent": "Indenter",
"menuEditor.unindent": "Désindenter",
"menuEditor.delete": "Supprimer",
"menuEditor.newEntryPlaceholder": "Saisissez un titre de page ou un libellé de sous-menu",
"menuEditor.newCategoryPlaceholder": "Saisissez un nom de catégorie",
"menuEditor.createHint": "Sélectionnez une page ci-dessous ou appuyez sur Entrée pour créer un sous-menu",
"menuEditor.dragHandle": "Faire glisser lentrée du menu",
"menuEditor.empty": "Aucune entrée de menu pour le moment. Ajoutez une page ou un sous-menu pour commencer.",
"menuEditor.newPage": "Nouvelle page",
"menuEditor.newSubmenu": "Nouveau sous-menu",
"menuEditor.pagePicker.title": "Sélectionner une page",
"menuEditor.pagePicker.empty": "Aucune page correspondante trouvée.",
"menuEditor.categoryPicker.hint": "Sélectionnez une catégorie existante ou appuyez sur Entrée pour créer une nouvelle entrée darchive",
"menuEditor.categoryPicker.empty": "Aucune catégorie correspondante trouvée.",
"menuEditor.type.page": "Page",
"menuEditor.type.home": "Accueil",
"menuEditor.type.submenu": "Sous-menu",
"menuEditor.type.categoryArchive": "Archive de catégorie",
"chat.newChat": "Nouveau chat", "chat.newChat": "Nouveau chat",
"chat.welcomeTitle": "Bienvenue dans lassistant IA", "chat.welcomeTitle": "Bienvenue dans lassistant IA",
"chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :", "chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :",
@@ -97,6 +126,115 @@
"sidebar.newPost": "Nouvel article", "sidebar.newPost": "Nouvel article",
"sidebar.importMedia": "Importer des médias", "sidebar.importMedia": "Importer des médias",
"sidebar.import.newDefinition": "Nouvelle définition", "sidebar.import.newDefinition": "Nouvelle définition",
"importAnalysis.loadingDefinition": "Chargement de la définition dimport…",
"importAnalysis.namePlaceholder": "Nom de la définition dimport",
"importAnalysis.headerDescription": "Analysez un fichier WXR avant import.",
"importAnalysis.uploadsFolder": "Dossier duploads",
"importAnalysis.noFolderSelected": "Aucun dossier sélectionné",
"importAnalysis.wxrFile": "Fichier WXR",
"importAnalysis.selectFileToAnalyze": "Sélectionnez un fichier à analyser",
"importAnalysis.analyzing": "Analyse…",
"importAnalysis.selectAndAnalyze": "Sélectionner et analyser",
"importAnalysis.analyzingWxr": "Analyse du fichier WXR…",
"importAnalysis.emptyState": "Sélectionnez un fichier WXR et lancez lanalyse.",
"importAnalysis.importing": "Import en cours…",
"importAnalysis.importComplete": "Import terminé : %{count}",
"importAnalysis.importFailed": "Échec de limport : %{error}",
"importAnalysis.untitledImport": "Import sans titre",
"importAnalysis.executionStarting": "Démarrage...",
"importAnalysis.unknownError": "Erreur inconnue",
"importAnalysis.readyToImport": "Prêt à importer :",
"importAnalysis.tagsCategories": "tags/catégories",
"importAnalysis.posts": "articles",
"importAnalysis.media": "médias",
"importAnalysis.pages": "pages",
"importAnalysis.nothingToImport": "Rien à importer",
"importAnalysis.importItems": "Importer %{count} éléments",
"importAnalysis.postSlugConflicts": "Conflits de slug darticle",
"importAnalysis.pageSlugConflicts": "Conflits de slug de page",
"importAnalysis.postsWithCount": "Articles (%{count})",
"importAnalysis.otherWithCount": "Autres (%{count})",
"importAnalysis.pagesWithCount": "Pages (%{count})",
"importAnalysis.mediaWithCount": "Médias (%{count})",
"importAnalysis.site": "Site",
"importAnalysis.untitled": "Sans titre",
"importAnalysis.url": "URL",
"importAnalysis.language": "Langue",
"importAnalysis.file": "Fichier",
"importAnalysis.notAvailable": "N/D",
"importAnalysis.new": "nouveau",
"importAnalysis.update": "mise à jour",
"importAnalysis.conflict": "conflit",
"importAnalysis.duplicate": "doublon",
"importAnalysis.missing": "manquant",
"importAnalysis.categories": "Catégories",
"importAnalysis.existing": "existant",
"importAnalysis.mapped": "mappé",
"importAnalysis.tags": "Tags",
"importAnalysis.dateDistribution": "Répartition par date",
"importAnalysis.postsPages": "Articles/Pages",
"importAnalysis.total": "total",
"importAnalysis.wordpressId": "ID WordPress",
"importAnalysis.type": "Type",
"importAnalysis.author": "Auteur",
"importAnalysis.unknown": "Inconnu",
"importAnalysis.published": "Publié",
"importAnalysis.excerpt": "Extrait",
"importAnalysis.content": "Contenu",
"importAnalysis.loading": "Chargement...",
"importAnalysis.mimeType": "Type MIME",
"importAnalysis.uploaded": "Téléversé",
"importAnalysis.parentPostId": "ID du post parent",
"importAnalysis.description": "Description",
"importAnalysis.slug": "Slug",
"importAnalysis.newEntryWxr": "Nouvelle entrée (WXR)",
"importAnalysis.existingEntry": "Entrée existante",
"importAnalysis.resolution": "Résolution",
"importAnalysis.ignore": "Ignorer",
"importAnalysis.overwrite": "Écraser",
"importAnalysis.importNewSlug": "Importer (nouveau slug)",
"importAnalysis.status": "Statut",
"importAnalysis.title": "Titre",
"importAnalysis.wpStatus": "Statut WP",
"importAnalysis.existingMatch": "Correspondance existante",
"importAnalysis.none": "--",
"importAnalysis.filename": "Nom de fichier",
"importAnalysis.path": "Chemin",
"importAnalysis.taxonomyTitle": "Catégories & Tags",
"importAnalysis.mappedCount": "%{count} mappé(s)",
"importAnalysis.analyzeWith": "Analyser avec...",
"importAnalysis.aiMappingHint": "LIA suggère des correspondances entre nouveaux éléments et éléments existants pour éviter les doublons",
"importAnalysis.mapToPlaceholder": "Mapper vers...",
"importAnalysis.mappingTooltip": "Cliquer pour %{action} le mapping",
"importAnalysis.mappingActionEdit": "modifier",
"importAnalysis.mappingActionAdd": "ajouter",
"importAnalysis.clearMapping": "Effacer le mapping",
"importAnalysis.macrosWithCount": "Macros (%{count})",
"importAnalysis.unmappedCount": "%{count} non mappé(s)",
"importAnalysis.macroStatusMapped": "Mappé",
"importAnalysis.macroStatusUnknown": "Inconnu",
"importAnalysis.macroUses": "%{count} utilisations",
"importAnalysis.usedIn": "Utilisé dans : %{items}%{more}",
"importAnalysis.moreSuffix": ", +%{count} de plus",
"importAnalysis.noParameters": "(aucun paramètre)",
"importAnalysis.other": "Autre",
"importAnalysis.otherDescription": "Éléments qui ne sont ni des articles ni des pages (révisions, menus de navigation, etc.) — non importés.",
"importAnalysis.browse": "Parcourir...",
"importAnalysis.eta": "Temps restant : %{value}",
"importAnalysis.etaSeconds": "%{count}s",
"importAnalysis.etaMinutes": "%{minutes}m %{seconds}s",
"importAnalysis.phase.tags": "Importation des étiquettes et catégories...",
"importAnalysis.phase.posts": "Importation des articles...",
"importAnalysis.phase.media": "Importation des médias...",
"importAnalysis.phase.pages": "Importation des pages...",
"importAnalysis.phase.complete": "Importation terminée",
"importAnalysis.analysisPhase.parsing": "Analyse du fichier WXR...",
"importAnalysis.analysisPhase.scanning": "Analyse des entrées...",
"importAnalysis.analysisPhase.taxonomies": "Analyse des taxonomies...",
"importAnalysis.analysisPhase.posts": "Analyse des articles...",
"importAnalysis.analysisPhase.media": "Analyse des médias...",
"importAnalysis.analysisPhase.complete": "Analyse terminée",
"importAnalysis.contentDuplicate": "doublon",
"sidebar.scripts.newScript": "Nouveau script", "sidebar.scripts.newScript": "Nouveau script",
"sidebar.templates.newTemplate": "Nouveau modèle", "sidebar.templates.newTemplate": "Nouveau modèle",
"sidebar.results": "%{count} résultats", "sidebar.results": "%{count} résultats",

View File

@@ -64,6 +64,35 @@
"translationValidation.revalidate": "Rivalidare", "translationValidation.revalidate": "Rivalidare",
"translationValidation.fix": "Correggi problemi", "translationValidation.fix": "Correggi problemi",
"translationValidation.toast.fixSuccess": "%{dbRows} righe DB e %{files} file eliminati, %{flushed} traduzioni scritte su disco", "translationValidation.toast.fixSuccess": "%{dbRows} righe DB e %{files} file eliminati, %{flushed} traduzioni scritte su disco",
"menuEditor.tabTitle": "Menu del blog",
"menuEditor.title": "Editor del menu del blog",
"menuEditor.description": "Gestisci la struttura centrale di navigazione del blog e salvala in meta/menu.opml.",
"menuEditor.save": "Salva menu",
"menuEditor.saved": "Menu del blog salvato",
"menuEditor.addEntry": "Aggiungi voce",
"menuEditor.addCategoryArchive": "Aggiungi archivio categoria",
"menuEditor.addCategoryArchiveShort": "C+",
"menuEditor.addSubmenu": "Aggiungi sottomenu",
"menuEditor.moveUp": "Sposta su",
"menuEditor.moveDown": "Sposta giù",
"menuEditor.indent": "Rientra",
"menuEditor.unindent": "Riduci rientro",
"menuEditor.delete": "Elimina",
"menuEditor.newEntryPlaceholder": "Digita un titolo pagina o un'etichetta del sottomenu",
"menuEditor.newCategoryPlaceholder": "Digita un nome categoria",
"menuEditor.createHint": "Seleziona una pagina qui sotto o premi Invio per creare un sottomenu",
"menuEditor.dragHandle": "Trascina voce di menu",
"menuEditor.empty": "Nessuna voce di menu ancora. Aggiungi una pagina o un sottomenu per iniziare.",
"menuEditor.newPage": "Nuova pagina",
"menuEditor.newSubmenu": "Nuovo sottomenu",
"menuEditor.pagePicker.title": "Seleziona pagina",
"menuEditor.pagePicker.empty": "Nessuna pagina corrispondente trovata.",
"menuEditor.categoryPicker.hint": "Seleziona una categoria esistente o premi Invio per creare una nuova voce di archivio",
"menuEditor.categoryPicker.empty": "Nessuna categoria corrispondente trovata.",
"menuEditor.type.page": "Pagina",
"menuEditor.type.home": "Home",
"menuEditor.type.submenu": "Sottomenu",
"menuEditor.type.categoryArchive": "Archivio categoria",
"chat.newChat": "Nuova chat", "chat.newChat": "Nuova chat",
"chat.setupTitle": "Configurazione chat IA", "chat.setupTitle": "Configurazione chat IA",
"chat.apiKeyRequiredTitle": "Chiave API richiesta", "chat.apiKeyRequiredTitle": "Chiave API richiesta",
@@ -97,6 +126,115 @@
"sidebar.newPost": "Nuovo post", "sidebar.newPost": "Nuovo post",
"sidebar.importMedia": "Importa media", "sidebar.importMedia": "Importa media",
"sidebar.import.newDefinition": "Nuova definizione", "sidebar.import.newDefinition": "Nuova definizione",
"importAnalysis.loadingDefinition": "Caricamento definizione di importazione…",
"importAnalysis.namePlaceholder": "Nome definizione di importazione",
"importAnalysis.headerDescription": "Analizza un file WXR prima dellimportazione.",
"importAnalysis.uploadsFolder": "Cartella uploads",
"importAnalysis.noFolderSelected": "Nessuna cartella selezionata",
"importAnalysis.wxrFile": "File WXR",
"importAnalysis.selectFileToAnalyze": "Seleziona un file da analizzare",
"importAnalysis.analyzing": "Analisi…",
"importAnalysis.selectAndAnalyze": "Seleziona e analizza",
"importAnalysis.analyzingWxr": "Analisi del file WXR…",
"importAnalysis.emptyState": "Seleziona un file WXR e avvia lanalisi.",
"importAnalysis.importing": "Importazione in corso…",
"importAnalysis.importComplete": "Importazione completata: %{count}",
"importAnalysis.importFailed": "Importazione non riuscita: %{error}",
"importAnalysis.untitledImport": "Importazione senza titolo",
"importAnalysis.executionStarting": "Avvio...",
"importAnalysis.unknownError": "Errore sconosciuto",
"importAnalysis.readyToImport": "Pronto per importare:",
"importAnalysis.tagsCategories": "tag/categorie",
"importAnalysis.posts": "articoli",
"importAnalysis.media": "media",
"importAnalysis.pages": "pagine",
"importAnalysis.nothingToImport": "Niente da importare",
"importAnalysis.importItems": "Importa %{count} elementi",
"importAnalysis.postSlugConflicts": "Conflitti slug articoli",
"importAnalysis.pageSlugConflicts": "Conflitti slug pagine",
"importAnalysis.postsWithCount": "Articoli (%{count})",
"importAnalysis.otherWithCount": "Altro (%{count})",
"importAnalysis.pagesWithCount": "Pagine (%{count})",
"importAnalysis.mediaWithCount": "Media (%{count})",
"importAnalysis.site": "Sito",
"importAnalysis.untitled": "Senza titolo",
"importAnalysis.url": "URL",
"importAnalysis.language": "Lingua",
"importAnalysis.file": "File",
"importAnalysis.notAvailable": "N/D",
"importAnalysis.new": "nuovo",
"importAnalysis.update": "aggiornamento",
"importAnalysis.conflict": "conflitto",
"importAnalysis.duplicate": "duplicato",
"importAnalysis.missing": "mancante",
"importAnalysis.categories": "Categorie",
"importAnalysis.existing": "esistente",
"importAnalysis.mapped": "mappato",
"importAnalysis.tags": "Tag",
"importAnalysis.dateDistribution": "Distribuzione per data",
"importAnalysis.postsPages": "Articoli/Pagine",
"importAnalysis.total": "totale",
"importAnalysis.wordpressId": "ID WordPress",
"importAnalysis.type": "Tipo",
"importAnalysis.author": "Autore",
"importAnalysis.unknown": "Sconosciuto",
"importAnalysis.published": "Pubblicato",
"importAnalysis.excerpt": "Estratto",
"importAnalysis.content": "Contenuto",
"importAnalysis.loading": "Caricamento...",
"importAnalysis.mimeType": "Tipo MIME",
"importAnalysis.uploaded": "Caricato",
"importAnalysis.parentPostId": "ID articolo padre",
"importAnalysis.description": "Descrizione",
"importAnalysis.slug": "Slug",
"importAnalysis.newEntryWxr": "Nuova voce (WXR)",
"importAnalysis.existingEntry": "Voce esistente",
"importAnalysis.resolution": "Risoluzione",
"importAnalysis.ignore": "Ignora",
"importAnalysis.overwrite": "Sovrascrivi",
"importAnalysis.importNewSlug": "Importa (nuovo slug)",
"importAnalysis.status": "Stato",
"importAnalysis.title": "Titolo",
"importAnalysis.wpStatus": "Stato WP",
"importAnalysis.existingMatch": "Corrispondenza esistente",
"importAnalysis.none": "--",
"importAnalysis.filename": "Nome file",
"importAnalysis.path": "Percorso",
"importAnalysis.taxonomyTitle": "Categorie & Tag",
"importAnalysis.mappedCount": "%{count} mappati",
"importAnalysis.analyzeWith": "Analizza con...",
"importAnalysis.aiMappingHint": "LIA suggerirà mappature da elementi nuovi a quelli esistenti per evitare duplicati",
"importAnalysis.mapToPlaceholder": "Mappa a...",
"importAnalysis.mappingTooltip": "Clicca per %{action} la mappatura",
"importAnalysis.mappingActionEdit": "modificare",
"importAnalysis.mappingActionAdd": "aggiungere",
"importAnalysis.clearMapping": "Cancella mappatura",
"importAnalysis.macrosWithCount": "Macro (%{count})",
"importAnalysis.unmappedCount": "%{count} non mappati",
"importAnalysis.macroStatusMapped": "Mappato",
"importAnalysis.macroStatusUnknown": "Sconosciuto",
"importAnalysis.macroUses": "%{count} utilizzi",
"importAnalysis.usedIn": "Usato in: %{items}%{more}",
"importAnalysis.moreSuffix": ", +%{count} altri",
"importAnalysis.noParameters": "(nessun parametro)",
"importAnalysis.other": "Altro",
"importAnalysis.otherDescription": "Elementi che non sono articoli o pagine (revisioni, menu di navigazione, ecc.) — non importati.",
"importAnalysis.browse": "Sfoglia...",
"importAnalysis.eta": "Tempo rimanente: %{value}",
"importAnalysis.etaSeconds": "%{count}s",
"importAnalysis.etaMinutes": "%{minutes}m %{seconds}s",
"importAnalysis.phase.tags": "Importazione di tag e categorie...",
"importAnalysis.phase.posts": "Importazione degli articoli...",
"importAnalysis.phase.media": "Importazione dei media...",
"importAnalysis.phase.pages": "Importazione delle pagine...",
"importAnalysis.phase.complete": "Importazione completata",
"importAnalysis.analysisPhase.parsing": "Analisi del file WXR...",
"importAnalysis.analysisPhase.scanning": "Scansione delle voci...",
"importAnalysis.analysisPhase.taxonomies": "Analisi delle tassonomie...",
"importAnalysis.analysisPhase.posts": "Analisi degli articoli...",
"importAnalysis.analysisPhase.media": "Analisi dei media...",
"importAnalysis.analysisPhase.complete": "Analisi completata",
"importAnalysis.contentDuplicate": "duplicato",
"sidebar.scripts.newScript": "Nuovo script", "sidebar.scripts.newScript": "Nuovo script",
"sidebar.templates.newTemplate": "Nuovo modello", "sidebar.templates.newTemplate": "Nuovo modello",
"sidebar.results": "%{count} risultati", "sidebar.results": "%{count} risultati",

View File

@@ -2550,6 +2550,295 @@ button svg * {
font: inherit; font: inherit;
} }
.menu-editor-view {
padding: 1rem;
height: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow: hidden;
background: var(--vscode-editor-background);
}
.menu-editor-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.menu-editor-header h2 {
margin: 0;
}
.menu-editor-header p {
margin: 0.25rem 0 0;
color: var(--vscode-descriptionForeground);
}
.menu-editor-main {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
overflow: hidden;
}
.menu-editor-tree-wrap {
display: flex;
flex-direction: column;
flex: 1;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-editor-background);
padding: 0.5rem;
min-height: 0;
}
.menu-editor-toolbar {
display: flex;
align-items: center;
gap: 0.2rem;
margin-bottom: 0.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--vscode-panel-border);
}
.menu-editor-tool {
width: 1.8rem;
height: 1.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--vscode-foreground);
cursor: pointer;
padding: 0;
}
.menu-editor-tool:hover:not(:disabled) {
background: var(--vscode-toolbar-hoverBackground);
border-color: var(--vscode-panel-border);
}
.menu-editor-tool:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.menu-editor-tree-shell {
flex: 1;
min-height: 0;
overflow: auto;
}
.menu-editor-tree-level {
list-style: none;
margin: 0;
padding: 0;
}
.menu-editor-tree-item {
margin: 0;
padding: 0;
}
.menu-editor-row {
--menu-editor-indent: calc(var(--menu-editor-depth) * 1rem);
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.3rem 0.45rem 0.3rem calc(0.4rem + var(--menu-editor-indent));
border-radius: 4px;
cursor: pointer;
position: relative;
}
.menu-editor-row.is-selected {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
.menu-editor-row.is-dragging {
opacity: 0.45;
}
.menu-editor-row.is-drop-before::before,
.menu-editor-row.is-drop-after::after {
content: "";
position: absolute;
left: calc(0.4rem + var(--menu-editor-indent));
right: 0.45rem;
height: 2px;
background: var(--vscode-focusBorder);
}
.menu-editor-row.is-drop-before::before {
top: 0;
}
.menu-editor-row.is-drop-after::after {
bottom: 0;
}
.menu-editor-row.is-drop-inside {
box-shadow: inset 0 0 0 1px var(--vscode-focusBorder);
background: var(--vscode-list-hoverBackground);
}
.menu-editor-row-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
min-width: 1rem;
color: var(--vscode-descriptionForeground);
cursor: grab;
user-select: none;
}
.menu-editor-row-handle:active {
cursor: grabbing;
}
.menu-editor-row-kind {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
min-width: 1rem;
opacity: 0.9;
}
.menu-editor-row-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-editor-row-title.is-editing {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.menu-editor-entry-form {
display: block;
}
.menu-editor-inline-input {
width: 100%;
border: 1px solid var(--vscode-focusBorder);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 0.25rem 0.45rem;
min-height: 1.8rem;
}
.menu-editor-inline-search {
margin-top: 0.5rem;
border-top: 1px solid var(--vscode-panel-border);
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 18rem;
overflow: hidden;
}
.menu-editor-inline-search-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.menu-editor-inline-search-head strong {
display: block;
font-size: 0.8rem;
}
.menu-editor-inline-search-head span {
color: var(--vscode-descriptionForeground);
font-size: 0.75rem;
}
.menu-editor-inline-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.menu-editor-inline-action {
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 4px;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
padding: 0.2rem 0.5rem;
cursor: pointer;
}
.menu-editor-inline-action:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.menu-editor-picker-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 16rem;
overflow-y: auto;
}
.menu-editor-picker-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 0.45rem 0.55rem;
text-align: left;
cursor: pointer;
}
.menu-editor-picker-item:hover {
border-color: var(--vscode-focusBorder);
background: var(--vscode-list-hoverBackground);
}
.menu-editor-picker-item small,
.menu-editor-picker-state {
color: var(--vscode-descriptionForeground);
}
.menu-editor-empty {
color: var(--vscode-descriptionForeground);
padding: 0.5rem 0.25rem;
}
@media (max-width: 720px) {
.menu-editor-inline-search-head {
flex-direction: column;
align-items: flex-start;
}
.menu-editor-inline-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
}
[data-testid="media-editor"] { [data-testid="media-editor"] {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -6869,6 +7158,695 @@ button svg * {
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.import-analysis {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px 20px 26px;
color: var(--vscode-foreground);
}
.import-analysis-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.import-analysis-header p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 13px;
line-height: 1.5;
}
.import-definition-name {
width: min(480px, 100%);
border: 1px solid var(--vscode-input-border, transparent);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground, var(--vscode-foreground));
border-radius: 6px;
padding: 10px 12px;
font-size: 18px;
font-weight: 600;
}
.import-file-selectors {
display: grid;
gap: 12px;
}
.import-file-row {
display: grid;
grid-template-columns: 150px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 76%, var(--vscode-input-background));
}
.import-file-row label {
font-size: 12px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-file-path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
font-size: 12px;
}
.import-file-path.placeholder {
color: var(--vscode-descriptionForeground);
}
.import-analysis button,
.import-analysis select {
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 6px;
font-size: 12px;
}
.import-analysis button {
background: var(--vscode-button-secondaryBackground, var(--vscode-button-background));
color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground));
padding: 8px 12px;
cursor: pointer;
}
.import-analysis button:hover:not(:disabled) {
background: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground));
}
.import-analyze-btn,
.import-execute-btn {
background: var(--vscode-button-background) !important;
color: var(--vscode-button-foreground) !important;
}
.import-analysis button:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.import-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--vscode-descriptionForeground);
border-top-color: var(--vscode-button-background);
border-radius: 50%;
animation: import-spinner-rotate 0.8s linear infinite;
flex-shrink: 0;
}
.import-progress {
display: flex;
flex-direction: column;
gap: 2px;
}
.import-progress-step {
font-size: 13px;
color: var(--vscode-foreground);
}
.import-progress-detail {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
@keyframes import-spinner-rotate {
to {
transform: rotate(360deg);
}
}
.import-site-info {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.import-site-info-item,
.import-stat-card,
.import-date-distribution,
.import-detail-section,
.import-execute-section {
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-site-info-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
}
.info-label {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-value {
font-size: 13px;
font-weight: 500;
overflow-wrap: anywhere;
}
.import-stat-cards {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.import-stat-card {
padding: 14px;
}
.import-stat-card h3,
.import-date-distribution h3,
.import-detail-section h3,
.taxonomy-group h4 {
margin: 0;
}
.import-stat-number {
margin-top: 10px;
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.import-stat-breakdown,
.import-execute-summary,
.import-taxonomy-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.import-stat-breakdown {
margin-top: 12px;
}
.import-stat-tag,
.import-count-tag,
.import-taxonomy-pill,
.macro-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.stat-new,
.import-taxonomy-pill.new-tax {
background: rgba(117, 190, 255, 0.16);
color: #75beff;
}
.stat-update,
.stat-mapped,
.import-taxonomy-pill.exists,
.import-taxonomy-pill.mapped,
.macro-status-badge.mapped,
.import-execution-complete {
background: rgba(115, 201, 145, 0.16);
color: #73c991;
}
.stat-conflict {
background: rgba(255, 166, 87, 0.16);
color: #ffb169;
}
.stat-duplicate,
.stat-missing,
.macro-status-badge.unmapped,
.import-execution-error {
background: rgba(204, 167, 0, 0.16);
color: #cca700;
}
.import-date-distribution,
.import-detail-section,
.import-execute-section {
padding: 16px;
}
.import-section-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0;
border: none !important;
background: transparent !important;
color: inherit !important;
font-size: 16px !important;
font-weight: 600;
text-align: left;
}
.import-section-toggle:hover {
background: transparent !important;
opacity: 0.9;
}
.toggle-icon {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.distribution-bars {
display: grid;
gap: 10px;
margin-top: 14px;
}
.distribution-row {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) 72px;
gap: 10px;
align-items: center;
}
.distribution-year,
.distribution-count,
.slug-cell {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
font-size: 11px;
}
.distribution-bar-container {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: var(--vscode-input-background);
}
.distribution-bar {
height: 100%;
min-width: 8px;
border-radius: inherit;
}
.distribution-bar-posts {
background: linear-gradient(90deg, rgba(117, 190, 255, 0.8), rgba(117, 190, 255, 0.35));
}
.import-execute-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.import-execute-summary {
color: var(--vscode-descriptionForeground);
}
.import-execution-complete,
.import-execution-error {
padding: 10px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
}
.import-execution-progress {
display: grid;
gap: 10px;
padding: 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-execution-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.import-execution-header h3 {
margin: 0;
font-size: 14px;
}
.import-progress-bar {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: var(--vscode-input-background);
}
.import-progress-fill {
height: 100%;
background: linear-gradient(90deg, rgba(117, 190, 255, 0.85), rgba(117, 190, 255, 0.45));
}
.import-progress-info {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
}
.import-phase {
font-weight: 600;
}
.import-detail,
.import-counter {
color: var(--vscode-descriptionForeground);
}
.import-detail-table {
width: 100%;
border-collapse: collapse;
margin-top: 14px;
}
.import-detail-table th,
.import-detail-table td {
padding: 10px 8px;
text-align: left;
border-bottom: 1px solid var(--vscode-panel-border);
vertical-align: middle;
font-size: 12px;
}
.import-detail-table th {
font-size: 11px;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-detail-table .status-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 9px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-detail-table .status-badge.new {
background: rgba(117, 190, 255, 0.16);
color: #75beff;
}
.import-detail-table .status-badge.update {
background: rgba(115, 201, 145, 0.16);
color: #73c991;
}
.import-detail-table .status-badge.conflict {
background: rgba(255, 166, 87, 0.16);
color: #ffb169;
}
.import-detail-table .status-badge.duplicate,
.import-detail-table .status-badge.missing {
background: rgba(204, 167, 0, 0.16);
color: #cca700;
}
.categories-cell,
.existing-match,
.mime-type-cell,
.post-type-cell {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.mime-type-cell,
.post-type-cell,
.existing-match,
.slug-cell {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
}
.resolution-select,
.taxonomy-mapping-input {
min-width: 150px;
background: var(--vscode-dropdown-background, var(--vscode-input-background));
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
padding: 6px 8px;
}
.taxonomy-analyze-row {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 12px;
margin-top: 12px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.taxonomy-analyze-dropdown {
position: relative;
}
.taxonomy-analyze-btn {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.taxonomy-model-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 220px;
max-height: 280px;
overflow-y: auto;
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
border-radius: 6px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
z-index: 20;
}
.taxonomy-model-option {
width: 100%;
display: block;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
color: var(--vscode-foreground) !important;
text-align: left;
padding: 8px 12px !important;
}
.taxonomy-model-option:hover {
background: var(--vscode-list-hoverBackground) !important;
}
.taxonomy-analyze-hint {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.import-taxonomy-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 14px;
}
.taxonomy-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
display: inline-flex;
align-items: center;
gap: 8px;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
flex-wrap: wrap;
}
.import-taxonomy-pill {
border: none;
cursor: default;
}
button.import-taxonomy-pill {
cursor: pointer;
}
.mapped-target {
background: rgba(115, 201, 145, 0.1);
}
.taxonomy-mapping-arrow {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.taxonomy-mapping-input {
min-width: 170px;
border-radius: 6px;
}
.taxonomy-edit-btn,
.taxonomy-clear-btn {
min-width: 28px;
min-height: 28px;
padding: 0 8px !important;
display: inline-flex;
align-items: center;
justify-content: center;
}
.taxonomy-edit-btn.ghost,
.taxonomy-clear-btn {
background: transparent !important;
border: 1px solid var(--vscode-panel-border) !important;
color: var(--vscode-descriptionForeground) !important;
}
.macros-list {
display: grid;
gap: 10px;
margin-top: 14px;
}
.macro-item {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: var(--vscode-input-background);
}
.macro-item.unmapped {
border-left: 3px solid #cca700;
}
.macro-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
}
.macro-name,
.import-taxonomy-pill {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
}
.macro-count {
margin-left: auto;
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.import-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 56px 20px;
color: var(--vscode-descriptionForeground);
border: 1px dashed var(--vscode-panel-border);
border-radius: 12px;
}
.import-empty-state p {
margin: 0;
font-size: 13px;
}
@media (max-width: 1100px) {
.import-site-info,
.import-stat-cards,
.import-taxonomy-groups {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 780px) {
.import-analysis {
padding: 14px;
}
.import-file-row,
.distribution-row,
.import-execute-section,
.import-site-info,
.import-stat-cards,
.import-taxonomy-groups {
grid-template-columns: 1fr;
}
.import-execute-section {
align-items: stretch;
}
.import-file-row {
align-items: stretch;
}
.import-analysis button,
.resolution-select,
.taxonomy-mapping-input {
width: 100%;
}
.taxonomy-analyze-row {
flex-direction: column;
align-items: stretch;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
width: 100%;
flex-direction: column;
align-items: stretch;
}
}
.load-more-button:hover:not(:disabled) { .load-more-button:hover:not(:disabled) {
background-color: var(--vscode-button-secondaryHoverBackground); background-color: var(--vscode-button-secondaryHoverBackground);
} }

View File

@@ -790,6 +790,141 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}, },
MenuEditorTree: {
mounted() {
this.dragItemId = null;
this.dragSourceEl = null;
this.dropTargetEl = null;
this.dropPosition = null;
this.clearDropTarget = () => {
if (this.dropTargetEl) {
this.dropTargetEl.classList.remove("is-drop-before", "is-drop-after", "is-drop-inside");
}
this.dropTargetEl = null;
this.dropPosition = null;
};
this.setDropTarget = (row, position) => {
if (this.dropTargetEl === row && this.dropPosition === position) {
return;
}
this.clearDropTarget();
this.dropTargetEl = row;
this.dropPosition = position;
row.classList.add(`is-drop-${position}`);
};
this.handleDragStart = (event) => {
const handle = event.target.closest("[data-menu-drag-handle='true']");
const row = event.target.closest("[data-menu-item-id]");
if (!handle || !row || !this.el.contains(row)) {
return;
}
this.dragItemId = row.dataset.menuItemId || null;
this.dragSourceEl = row;
row.classList.add("is-dragging");
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", this.dragItemId || "");
}
};
this.handleDragOver = (event) => {
const row = event.target.closest("[data-menu-item-id]");
if (!this.dragItemId || !row || !this.el.contains(row)) {
this.clearDropTarget();
return;
}
const targetItemId = row.dataset.menuItemId || "";
if (!targetItemId || targetItemId === this.dragItemId) {
this.clearDropTarget();
return;
}
event.preventDefault();
const rect = row.getBoundingClientRect();
const offsetY = event.clientY - rect.top;
const allowInside = row.dataset.menuCanDropInside === "true";
const insideBandTop = rect.height * 0.3;
const insideBandBottom = rect.height * 0.7;
const position =
allowInside && offsetY >= insideBandTop && offsetY <= insideBandBottom
? "inside"
: offsetY < rect.height / 2
? "before"
: "after";
this.setDropTarget(row, position);
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
};
this.handleDrop = (event) => {
const row = event.target.closest("[data-menu-item-id]");
if (!this.dragItemId || !row || !this.el.contains(row) || !this.dropPosition) {
this.clearDropTarget();
return;
}
event.preventDefault();
this.pushEvent("menu_editor_drop_item", {
drag_item_id: this.dragItemId,
target_item_id: row.dataset.menuItemId,
position: this.dropPosition
});
this.clearDropTarget();
};
this.handleDragLeave = (event) => {
const related = event.relatedTarget;
if (this.dropTargetEl && (!related || !this.dropTargetEl.contains(related))) {
this.clearDropTarget();
}
};
this.handleDragEnd = () => {
if (this.dragSourceEl) {
this.dragSourceEl.classList.remove("is-dragging");
}
this.dragItemId = null;
this.dragSourceEl = null;
this.clearDropTarget();
};
this.el.addEventListener("dragstart", this.handleDragStart);
this.el.addEventListener("dragover", this.handleDragOver);
this.el.addEventListener("drop", this.handleDrop);
this.el.addEventListener("dragleave", this.handleDragLeave);
this.el.addEventListener("dragend", this.handleDragEnd);
},
destroyed() {
this.el.removeEventListener("dragstart", this.handleDragStart);
this.el.removeEventListener("dragover", this.handleDragOver);
this.el.removeEventListener("drop", this.handleDrop);
this.el.removeEventListener("dragleave", this.handleDragLeave);
this.el.removeEventListener("dragend", this.handleDragEnd);
}
},
MonacoEditor: { MonacoEditor: {
mounted() { mounted() {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");

View File

@@ -119,6 +119,16 @@ defmodule BDS.AITest do
usage: usage(13, 9, 0, 0) usage: usage(13, 9, 0, 0)
}} }}
:import_taxonomy_mapping ->
{:ok,
%{
json: %{
"categoryMappings" => %{"General" => "article", "Unknown" => "missing"},
"tagMappings" => %{"News" => "updates", "Ghost" => "missing"}
},
usage: usage(19, 7, 0, 0)
}}
:chat -> :chat ->
if Enum.any?(request.messages, &(&1["role"] == "tool")) do if Enum.any?(request.messages, &(&1["role"] == "tool")) do
{:ok, {:ok,
@@ -309,6 +319,36 @@ defmodule BDS.AITest do
assert request.model == "gpt-4.1-mini" assert request.model == "gpt-4.1-mini"
end end
test "analyze_import_taxonomy uses the selected model override and returns only valid existing-term mappings" do
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(:online, %{
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
}, secret_backend: FakeSecretBackend)
assert :ok = BDS.AI.set_airplane_mode(false)
assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini")
assert {:ok, result} =
BDS.AI.analyze_import_taxonomy(
%{categories: ["General"], tags: ["News"]},
%{categories: ["article", "page"], tags: ["updates"]},
runtime: FakeRuntime,
test_pid: self(),
secret_backend: FakeSecretBackend,
model: "gpt-4o"
)
assert result.category_mappings == %{"General" => "article"}
assert result.tag_mappings == %{"News" => "updates"}
assert_received {:runtime_request, endpoint, request}
assert endpoint.kind == :online
assert request.operation == :import_taxonomy_mapping
assert request.model == "gpt-4o"
end
test "analyze_image requires a vision-capable airplane model before sending image input" do test "analyze_image requires a vision-capable airplane model before sending image input" do
assert {:ok, _endpoint} = assert {:ok, _endpoint} =
BDS.AI.put_endpoint(:airplane, %{ BDS.AI.put_endpoint(:airplane, %{

View File

@@ -4,6 +4,7 @@ defmodule BDS.CliSyncTest do
import Ecto.Query import Ecto.Query
alias BDS.CliSync alias BDS.CliSync
alias BDS.CliSync.Watcher
alias BDS.Repo alias BDS.Repo
setup do setup do
@@ -24,6 +25,25 @@ defmodule BDS.CliSyncTest do
assert is_integer(seen_notification.seen_at) assert is_integer(seen_notification.seen_at)
end end
test "watcher broadcasts entity change events after database mutations are detected" do
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
watcher =
start_supervised!({Watcher, poll_interval_ms: 60_000, debounce_ms: 0})
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), watcher)
assert {:ok, notification} = CliSync.cli_mutation_performed("post", "post-1", :updated)
:ok = Watcher.poll_now(watcher)
assert_receive {:entity_changed, %{entity: "post", entity_id: "post-1", action: :updated}}, 500
seen_notification = Repo.get!(BDS.CliSync.Notification, notification.id)
assert is_integer(seen_notification.seen_at)
end
test "processed notifications are pruned after one hour and unprocessed notifications after one day" do test "processed notifications are pruned after one hour and unprocessed notifications after one day" do
now = BDS.Persistence.now_ms() now = BDS.Persistence.now_ms()

View File

@@ -0,0 +1,206 @@
defmodule BDS.Desktop.ImportShellLiveTest do
use ExUnit.Case, async: false
import Phoenix.ConnTest
import Phoenix.LiveViewTest
alias BDS.ImportDefinitions
alias BDS.Projects
@endpoint BDS.Desktop.Endpoint
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
temp_dir = Path.join(System.tmp_dir!(), "bds-import-shell-live-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = Projects.create_project(%{name: "Import Shell", data_path: temp_dir})
{:ok, _project} = Projects.set_active_project(project.id)
%{project: project, temp_dir: temp_dir}
end
test "opening an import definition renders the dedicated import analysis editor instead of the fallback shell frame", %{project: project, temp_dir: temp_dir} do
uploads_dir = Path.join(temp_dir, "uploads")
wxr_path = Path.join(temp_dir, "legacy.xml")
assert {:ok, definition} =
ImportDefinitions.create_definition(%{
project_id: project.id,
name: "Legacy Import",
wxr_file_path: wxr_path,
uploads_folder_path: uploads_dir,
last_analysis_result: Jason.encode!(cached_report(wxr_path, uploads_dir))
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = render_click(view, "select_view", %{"view" => "import"})
html =
view
|> element("[data-testid='sidebar-open-item'][data-item-id='#{definition.id}']")
|> render_click()
assert html =~ ~s(data-testid="import-editor")
assert html =~ ~s(data-testid="import-editor-form")
assert html =~ "Legacy Import"
assert html =~ "Uploads Folder"
assert html =~ "WXR File"
assert html =~ "Ready to import:"
assert html =~ "Import 5 Items"
assert html =~ "Post Slug Conflicts"
assert html =~ "Analyze with..."
assert html =~ "Posts (2)"
assert html =~ "Pages (1)"
assert html =~ "Media (1)"
assert html =~ "Click to add mapping"
refute html =~ ~s(name="mapped_to")
refute html =~ "Desktop workbench content routed through the Elixir shell."
posts_html =
view
|> element("button[phx-value-section='posts']")
|> render_click()
assert posts_html =~ "Existing Match"
assert posts_html =~ "WP Status"
media_html =
view
|> element("button[phx-value-section='media']")
|> render_click()
assert media_html =~ "Filename"
assert media_html =~ "Path"
end
defp cached_report(wxr_path, uploads_dir) do
%{
source_file: wxr_path,
site_info: %{
title: "Legacy Blog",
url: "https://legacy.example",
language: "en",
source_file: wxr_path
},
post_stats: %{new_count: 1, update_count: 0, conflict_count: 1, duplicate_count: 0},
page_stats: %{new_count: 1, update_count: 0, conflict_count: 0, duplicate_count: 0},
media_stats: %{new_count: 1, update_count: 0, conflict_count: 0, duplicate_count: 0, missing_count: 0},
category_stats: %{existing_count: 0, mapped_count: 0, new_count: 1},
tag_stats: %{existing_count: 0, mapped_count: 0, new_count: 1},
date_distribution: [%{year: 2024, post_count: 2, media_count: 1}],
conflicts: [
%{
item_type: "post",
item_name: "hello-world",
resolution: "ignore",
source_title: "Hello World",
existing_title: "Existing Hello"
}
],
macros: %{
total: 1,
mapped_count: 0,
unmapped_count: 1,
discovered: [
%{
name: "gallery",
mapped: false,
total_count: 1,
usages: [%{params: %{"ids" => "1,2"}, count: 1, validation_status: "unknown"}],
post_slugs: ["hello-world"]
}
]
},
items: %{
posts: [
%{item_type: "post", title: "Hello World", slug: "hello-world", status: "new"},
%{item_type: "post", title: "Conflict Me", slug: "conflict-me", status: "conflict", resolution: "ignore"}
],
pages: [
%{item_type: "page", title: "About", slug: "about", status: "new"}
],
media: [
%{
item_type: "media",
title: "Import Asset",
filename: "import-asset.txt",
relative_path: "2024/05/import-asset.txt",
source_file: Path.join(uploads_dir, "2024/05/import-asset.txt"),
status: "new"
}
],
categories: [%{name: "General", exists_in_project: false, mapped_to: nil}],
tags: [%{name: "News", exists_in_project: false, mapped_to: nil}]
},
details: %{
posts: [
%{
item_type: "post",
title: "Hello World",
slug: "hello-world",
status: "new",
wp_status: "publish",
author: "Importer",
categories: ["General"],
tags: ["News"],
published_at: "2024-05-01 12:00:00",
excerpt: "Legacy hello",
content_markdown: "Hello world",
content_preview: "Hello world"
},
%{
item_type: "post",
title: "Conflict Me",
slug: "conflict-me",
status: "conflict",
resolution: "ignore",
wp_status: "publish",
author: "Importer",
categories: ["General"],
tags: ["News"],
published_at: "2024-05-02 12:00:00",
excerpt: "Legacy conflict",
existing_title: "Existing Conflict",
content_markdown: "Incoming conflict body",
content_preview: "Incoming conflict body"
}
],
pages: [
%{
item_type: "page",
title: "About",
slug: "about",
status: "new",
wp_status: "publish",
author: "Importer",
categories: ["General"],
tags: [],
published_at: "2024-05-03 12:00:00",
excerpt: "About page",
content_markdown: "About page",
content_preview: "About page"
}
],
media: [
%{
item_type: "media",
title: "Import Asset",
filename: "import-asset.txt",
relative_path: "2024/05/import-asset.txt",
source_file: Path.join(uploads_dir, "2024/05/import-asset.txt"),
status: "new",
mime_type: "text/plain",
description: "Legacy text attachment",
parent_wp_id: 101,
created_at: "2024-05-03 12:00:00"
}
]
}
}
end
end

View File

@@ -6,6 +6,8 @@ defmodule BDS.Desktop.ShellLiveTest do
alias BDS.Persistence alias BDS.Persistence
alias BDS.AI alias BDS.AI
alias BDS.CliSync.Watcher
alias BDS.Menu
alias BDS.Media alias BDS.Media
alias BDS.Metadata alias BDS.Metadata
alias BDS.Posts alias BDS.Posts
@@ -202,6 +204,71 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-id="#{created_definition.id}") assert html =~ ~s(data-tab-id="#{created_definition.id}")
end end
test "shell live refreshes the posts sidebar when the CLI watcher broadcasts an entity change", %{project: project} do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
refute html =~ "CLI Added Post"
assert {:ok, post} = Posts.create_post(%{project_id: project.id, title: "CLI Added Post"})
Phoenix.PubSub.broadcast(BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "post", entity_id: post.id, action: :created}})
assert render(view) =~ "CLI Added Post"
end
test "shell live closes stale post and media tabs when the CLI watcher broadcasts deletions", %{project: project, temp_dir: temp_dir} do
assert {:ok, post} = Posts.create_post(%{project_id: project.id, title: "CLI Delete Post"})
source_path = Path.join(temp_dir, "cli-delete-media.txt")
File.write!(source_path, "media body")
assert {:ok, media} =
Media.import_media(%{
project_id: project.id,
source_path: source_path,
title: "CLI Delete Media"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
view
|> element("[data-testid='sidebar-open-item'][data-item-id='#{post.id}']")
|> render_click()
assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="#{post.id}")
assert {:ok, :deleted} = Posts.delete_post(post.id)
Phoenix.PubSub.broadcast(BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "post", entity_id: post.id, action: :deleted}})
html = render(view)
refute html =~ ~s(data-tab-type="post")
refute html =~ "CLI Delete Post"
_html =
view
|> element("[data-testid='activity-button'][data-view='media']")
|> render_click()
html =
view
|> element("[data-testid='sidebar-open-item'][data-item-id='#{media.id}']")
|> render_click()
assert html =~ ~s(data-tab-type="media")
assert html =~ ~s(data-tab-id="#{media.id}")
assert {:ok, :deleted} = Media.delete_media(media.id)
Phoenix.PubSub.broadcast(BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "media", entity_id: media.id, action: :deleted}})
html = render(view)
refute html =~ ~s(data-tab-type="media")
refute html =~ "CLI Delete Media"
end
test "shell live owns pane visibility and activity selection on the server" do test "shell live owns pane visibility and activity selection on the server" do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
@@ -375,6 +442,114 @@ defmodule BDS.Desktop.ShellLiveTest do
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown") refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
end end
test "native edit menu action opens the dedicated menu editor surface", %{project: project} do
assert {:ok, _menu} =
Menu.update_menu(project.id, [
%{kind: :page, label: "About", slug: "about"},
%{
kind: :submenu,
label: "Sections",
children: [
%{kind: :page, label: "Contact", slug: "contact"}
]
}
])
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html = render_hook(view, "native_menu_action", %{"action" => "edit_menu"})
assert html =~ ~s(data-testid="menu-editor")
assert html =~ "Blog Menu Editor"
assert html =~ "meta/menu.opml"
assert html =~ ~s(data-testid="menu-editor-toolbar")
assert html =~ ~s(data-testid="menu-editor-toolbar-button")
assert html =~ ~s(data-action="add-entry")
assert html =~ ~s(data-action="save")
assert html =~ ~s(data-action="indent")
assert html =~ ~s(data-action="unindent")
assert html =~ ~s(data-testid="menu-editor-row")
assert html =~ ~s(data-menu-label="Home")
assert html =~ ~s(data-menu-label="About")
assert html =~ ~s(data-menu-label="Sections")
refute html =~ "Desktop workbench content routed through the Elixir shell."
end
test "menu editor adds a submenu, nests an entry, and saves the opml", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, _menu} =
Menu.update_menu(project.id, [
%{kind: :page, label: "Contact", slug: "contact"}
])
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = render_hook(view, "native_menu_action", %{"action" => "edit_menu"})
html =
view
|> element("[data-testid='menu-editor-toolbar-button'][data-action='add-entry']")
|> render_click()
assert html =~ ~s(data-testid="menu-editor-entry-form")
html =
view
|> form("[data-testid='menu-editor-entry-form']", %{
menu_editor_entry: %{"query" => "Sections"}
})
|> render_change()
assert html =~ ~s(value="Sections")
html =
view
|> form("[data-testid='menu-editor-entry-form']", %{
menu_editor_entry: %{"query" => "Sections"}
})
|> render_submit()
assert html =~ ~s(data-menu-label="Sections")
html =
view
|> element("[data-testid='menu-editor-row'][data-menu-label='Contact']")
|> render_click()
assert html =~ ~s(data-selected="true")
_html =
view
|> element("[data-testid='menu-editor-toolbar-button'][data-action='indent']")
|> render_click()
_html =
view
|> element("[data-testid='menu-editor-toolbar-button'][data-action='save']")
|> render_click()
assert {:ok, menu} = Menu.get_menu(project.id)
assert menu.items == [
%{kind: :home, label: "Home", slug: nil},
%{
kind: :submenu,
label: "Sections",
slug: nil,
children: [
%{kind: :page, label: "Contact", slug: "contact"}
]
}
]
opml_path = Path.join([temp_dir, "meta", "menu.opml"])
contents = File.read!(opml_path)
assert contents =~ ~s(<outline text="Sections" type="submenu">)
assert contents =~ ~s(<outline text="Contact" type="page" pageSlug="contact")
end
test "workbench session restore reopens permanent and transient tabs and selected activity" do test "workbench session restore reopens permanent and transient tabs and selected activity" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
@@ -2054,6 +2229,66 @@ defmodule BDS.Desktop.ShellLiveTest do
refute render(view) =~ "Delayed response" refute render(view) =~ "Delayed response"
end end
test "chat editor keeps the in-flight user turn visible and disables input while streaming" do
assert :ok = AI.set_airplane_mode(false)
server =
start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
assert {:ok, _endpoint} =
AI.put_endpoint(:online, %{
url: "http://127.0.0.1:#{port}/v1",
api_key: "online-secret",
model: "gpt-4.1"
})
assert {:ok, conversation} = AI.start_chat(%{title: "Pending Chat", model: "gpt-4.1"})
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :assistant,
content: "Earlier answer",
created_at: Persistence.now_ms()
})
)
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html =
render_click(view, "pin_sidebar_item", %{
"route" => "chat",
"id" => conversation.id,
"title" => conversation.title,
"subtitle" => conversation.model || "chat"
})
_html = render_change(view, "change_chat_editor_input", %{"message" => "Newest question"})
html =
view
|> element("[data-testid='chat-send-button']")
|> render_click()
{assistant_index, _length} = :binary.match(html, "Earlier answer")
{user_index, _length} = :binary.match(html, "Newest question")
assert assistant_index < user_index
assert html =~ ~r/<textarea[^>]*class="chat-input chat-surface-input"[^>]*disabled/
html =
view
|> element("[data-testid='chat-abort-button']")
|> render_click()
refute html =~ ~s(data-testid="chat-abort-button")
Process.sleep(350)
refute render(view) =~ "Delayed response"
end
test "translation validation route renders dedicated cards and fix controls", %{project: project, temp_dir: temp_dir} do test "translation validation route renders dedicated cards and fix controls", %{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} = assert {:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{ BDS.Metadata.update_project_metadata(project.id, %{

View File

@@ -0,0 +1,332 @@
defmodule BDS.ImportAnalysisTest do
use ExUnit.Case, async: false
alias BDS.ImportAnalysis
alias BDS.Media
alias BDS.Posts
alias BDS.Tags
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-import-analysis-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "Import Analysis", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
test "analyze_wxr summarizes new items, date distribution, and macros", %{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)
assert report.site_info.title == "Legacy Blog"
assert report.site_info.url == "https://legacy.example"
assert report.site_info.language == "en"
assert report.site_info.source_file == wxr_path
assert report.post_stats == %{new_count: 1, update_count: 0, conflict_count: 0, duplicate_count: 0}
assert report.page_stats == %{new_count: 1, update_count: 0, conflict_count: 0, duplicate_count: 0}
assert report.media_stats == %{
new_count: 1,
update_count: 0,
conflict_count: 0,
duplicate_count: 0,
missing_count: 0
}
assert report.category_stats == %{existing_count: 0, mapped_count: 0, new_count: 1}
assert report.tag_stats == %{existing_count: 0, mapped_count: 0, new_count: 1}
assert Enum.any?(report.date_distribution, fn row ->
row.year == 2024 and row.post_count == 2 and row.media_count == 1
end)
assert %{
total: 1,
mapped_count: 0,
unmapped_count: 1,
discovered: [
%{
name: "gallery",
mapped: false,
total_count: 1,
usages: [%{params: %{"ids" => "1,2"}, count: 1, validation_status: "unknown"}],
post_slugs: ["hello-world"]
}
]
} = report.macros
assert report.conflicts == []
assert [%{title: "Hello World", slug: "hello-world", status: "new", item_type: "post", post_type: "post"}] =
report.items.posts
assert [%{title: "About", slug: "about", status: "new", item_type: "page"} = page_item] = report.items.pages
assert Map.get(page_item, :post_type) == "page"
assert [%{title: "Import Asset", filename: "import-asset.txt", relative_path: "2024/05/import-asset.txt", status: "new", item_type: "media"}] =
report.items.media
end
test "analyze_wxr detects update, conflict, duplicate, existing taxonomy, and missing uploads", %{project: project, temp_dir: temp_dir} do
assert {:ok, _category} = Tags.create_tag(%{project_id: project.id, name: "General"})
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "News"})
assert {:ok, _update_post} =
Posts.create_post(%{
project_id: project.id,
title: "Update Me",
content: "Update body",
checksum: sha256("Update body")
})
assert {:ok, _conflict_post} =
Posts.create_post(%{
project_id: project.id,
title: "Conflict Me",
content: "Local body",
checksum: sha256("Local body")
})
assert {:ok, _duplicate_post} =
Posts.create_post(%{
project_id: project.id,
title: "Existing Duplicate",
content: "Duplicate body",
checksum: sha256("Duplicate body")
})
existing_media_source = Path.join(temp_dir, "update-asset.txt")
File.write!(existing_media_source, "shared bytes")
assert {:ok, _existing_media} =
Media.import_media(%{
project_id: project.id,
source_path: existing_media_source,
title: "Update Asset"
})
wxr_path = Path.join(temp_dir, "conflicts.xml")
File.write!(wxr_path, conflict_wxr_xml())
assert {:ok, report} = ImportAnalysis.analyze_wxr(project.id, wxr_path, nil)
assert report.post_stats == %{new_count: 0, update_count: 1, conflict_count: 1, duplicate_count: 1}
assert report.page_stats == %{new_count: 0, update_count: 0, conflict_count: 0, duplicate_count: 0}
assert report.media_stats == %{
new_count: 0,
update_count: 0,
conflict_count: 0,
duplicate_count: 0,
missing_count: 1
}
assert report.category_stats == %{existing_count: 1, mapped_count: 0, new_count: 0}
assert report.tag_stats == %{existing_count: 1, mapped_count: 0, new_count: 0}
assert Enum.any?(report.conflicts, fn conflict ->
conflict.item_type == "post" and conflict.item_name == "conflict-me" and conflict.resolution == "ignore"
end)
assert Enum.any?(report.items.posts, &(&1.slug == "update-me" and &1.status == "update"))
assert Enum.any?(report.items.posts, &(&1.slug == "conflict-me" and &1.status == "conflict"))
assert Enum.any?(report.items.posts, &(&1.slug == "duplicate-me" and &1.status == "content-duplicate"))
assert Enum.any?(report.items.media, &(&1.filename == "missing-asset.txt" and &1.status == "missing"))
end
test "analyze_wxr reports legacy progress steps while building the import report", %{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,
on_progress: fn step, detail ->
send(self(), {:analysis_progress, step, detail})
end
)
assert_received {:analysis_progress, "Loading existing posts...", nil}
assert_received {:analysis_progress, "Analyzing posts...", "1 posts to analyze"}
assert_received {:analysis_progress, "Analyzing pages...", "1 pages to analyze"}
assert_received {:analysis_progress, "Analyzing media files...", "1 media files to analyze"}
assert_received {:analysis_progress, "Discovering macros...", nil}
end
defp sha256(value) do
:sha256
|> :crypto.hash(value)
|> Base.encode16(case: :lower)
end
defp basic_wxr_xml do
"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:wp="http://wordpress.org/export/1.2/">
<channel>
<title>Legacy Blog</title>
<link>https://legacy.example</link>
<description>Imported from the legacy desktop app</description>
<language>en</language>
<wp:category>
<wp:cat_name><![CDATA[General]]></wp:cat_name>
<wp:category_nicename>general</wp:category_nicename>
<wp:category_parent></wp:category_parent>
</wp:category>
<wp:tag>
<wp:tag_slug>news</wp:tag_slug>
<wp:tag_name><![CDATA[News]]></wp:tag_name>
</wp:tag>
<item>
<title>Hello World</title>
<link>https://legacy.example/2024/05/hello-world</link>
<pubDate>Wed, 01 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[<p>Hello world</p><p>[gallery ids="1,2"]</p>]]></content:encoded>
<excerpt:encoded><![CDATA[Legacy hello]]></excerpt:encoded>
<wp:post_id>101</wp:post_id>
<wp:post_date>2024-05-01 12:00:00</wp:post_date>
<wp:post_modified>2024-05-01 12:30:00</wp:post_modified>
<wp:post_name>hello-world</wp:post_name>
<wp:status>publish</wp:status>
<wp:post_type>post</wp:post_type>
<category domain="category" nicename="general"><![CDATA[General]]></category>
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
</item>
<item>
<title>About</title>
<link>https://legacy.example/about</link>
<pubDate>Thu, 02 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[<p>About page</p>]]></content:encoded>
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
<wp:post_id>201</wp:post_id>
<wp:post_date>2024-05-02 12:00:00</wp:post_date>
<wp:post_modified>2024-05-02 12:30:00</wp:post_modified>
<wp:post_name>about</wp:post_name>
<wp:status>publish</wp:status>
<wp:post_type>page</wp:post_type>
<category domain="category" nicename="general"><![CDATA[General]]></category>
</item>
<item>
<title>Import Asset</title>
<link>https://legacy.example/wp-content/uploads/2024/05/import-asset.txt</link>
<pubDate>Fri, 03 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[Legacy text attachment]]></content:encoded>
<wp:post_id>301</wp:post_id>
<wp:post_parent>101</wp:post_parent>
<wp:post_name>import-asset</wp:post_name>
<wp:status>inherit</wp:status>
<wp:post_type>attachment</wp:post_type>
<wp:attachment_url><![CDATA[https://legacy.example/wp-content/uploads/2024/05/import-asset.txt]]></wp:attachment_url>
</item>
</channel>
</rss>
"""
end
defp conflict_wxr_xml do
"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:wp="http://wordpress.org/export/1.2/">
<channel>
<title>Legacy Blog</title>
<link>https://legacy.example</link>
<description>Imported from the legacy desktop app</description>
<language>en</language>
<wp:category>
<wp:cat_name><![CDATA[General]]></wp:cat_name>
<wp:category_nicename>general</wp:category_nicename>
<wp:category_parent></wp:category_parent>
</wp:category>
<wp:tag>
<wp:tag_slug>news</wp:tag_slug>
<wp:tag_name><![CDATA[News]]></wp:tag_name>
</wp:tag>
<item>
<title>Update Me</title>
<link>https://legacy.example/update-me</link>
<pubDate>Wed, 01 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[<p>Update body</p>]]></content:encoded>
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
<wp:post_id>401</wp:post_id>
<wp:post_date>2024-05-01 12:00:00</wp:post_date>
<wp:post_modified>2024-05-01 12:30:00</wp:post_modified>
<wp:post_name>update-me</wp:post_name>
<wp:status>publish</wp:status>
<wp:post_type>post</wp:post_type>
<category domain="category" nicename="general"><![CDATA[General]]></category>
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
</item>
<item>
<title>Conflict Me</title>
<link>https://legacy.example/conflict-me</link>
<pubDate>Thu, 02 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[<p>Incoming conflict body</p>]]></content:encoded>
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
<wp:post_id>402</wp:post_id>
<wp:post_date>2024-05-02 12:00:00</wp:post_date>
<wp:post_modified>2024-05-02 12:30:00</wp:post_modified>
<wp:post_name>conflict-me</wp:post_name>
<wp:status>publish</wp:status>
<wp:post_type>post</wp:post_type>
<category domain="category" nicename="general"><![CDATA[General]]></category>
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
</item>
<item>
<title>Duplicate Me</title>
<link>https://legacy.example/duplicate-me</link>
<pubDate>Fri, 03 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[<p>Duplicate body</p>]]></content:encoded>
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
<wp:post_id>403</wp:post_id>
<wp:post_date>2024-05-03 12:00:00</wp:post_date>
<wp:post_modified>2024-05-03 12:30:00</wp:post_modified>
<wp:post_name>duplicate-me</wp:post_name>
<wp:status>publish</wp:status>
<wp:post_type>post</wp:post_type>
<category domain="category" nicename="general"><![CDATA[General]]></category>
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
</item>
<item>
<title>Missing Asset</title>
<link>https://legacy.example/wp-content/uploads/2024/05/missing-asset.txt</link>
<pubDate>Sat, 04 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[Missing attachment]]></content:encoded>
<wp:post_id>404</wp:post_id>
<wp:post_parent>401</wp:post_parent>
<wp:post_name>missing-asset</wp:post_name>
<wp:status>inherit</wp:status>
<wp:post_type>attachment</wp:post_type>
<wp:attachment_url><![CDATA[https://legacy.example/wp-content/uploads/2024/05/missing-asset.txt]]></wp:attachment_url>
</item>
</channel>
</rss>
"""
end
end

View File

@@ -0,0 +1,57 @@
defmodule BDS.ImportDefinitionsTest do
use ExUnit.Case, async: false
alias BDS.ImportDefinitions
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-import-definitions-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "Import Definitions", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
test "get, update, and delete round-trip import definition editor state", %{project: project, temp_dir: temp_dir} do
uploads_folder_path = Path.join(temp_dir, "uploads")
wxr_file_path = Path.join(temp_dir, "legacy.xml")
assert {:ok, definition} =
ImportDefinitions.create_definition(%{
project_id: project.id,
name: "Legacy Import",
wxr_file_path: wxr_file_path,
uploads_folder_path: uploads_folder_path,
last_analysis_result: Jason.encode!(%{site_info: %{title: "Legacy Blog"}})
})
fetched = ImportDefinitions.get_definition(definition.id)
assert fetched.id == definition.id
assert fetched.project_id == project.id
assert fetched.name == "Legacy Import"
assert fetched.wxr_file_path == wxr_file_path
assert fetched.uploads_folder_path == uploads_folder_path
assert fetched.last_analysis_result == Jason.encode!(%{site_info: %{title: "Legacy Blog"}})
assert {:ok, updated} =
ImportDefinitions.update_definition(definition.id, %{
name: "Renamed Import",
wxr_file_path: Path.join(temp_dir, "renamed.xml"),
uploads_folder_path: Path.join(temp_dir, "renamed-uploads"),
last_analysis_result: %{site_info: %{title: "Renamed Blog"}, post_stats: %{new_count: 2}}
})
assert updated.name == "Renamed Import"
assert updated.wxr_file_path == Path.join(temp_dir, "renamed.xml")
assert updated.uploads_folder_path == Path.join(temp_dir, "renamed-uploads")
assert updated.last_analysis_result == Jason.encode!(%{site_info: %{title: "Renamed Blog"}, post_stats: %{new_count: 2}})
assert [%{id: listed_id, title: "Renamed Import"}] = ImportDefinitions.list_definitions(project.id)
assert listed_id == definition.id
assert {:ok, :deleted} = ImportDefinitions.delete_definition(definition.id)
assert ImportDefinitions.get_definition(definition.id) == nil
end
end

View File

@@ -0,0 +1,233 @@
defmodule BDS.ImportExecutionTest do
use ExUnit.Case, async: false
import Ecto.Query
alias BDS.ImportAnalysis
alias BDS.ImportExecution
alias BDS.Posts
alias BDS.Repo
alias BDS.Tags
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-import-execution-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "Import Execution", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
test "execute_import creates tags, posts, pages, and media from the analysis report", %{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)
assert {:ok, result} =
ImportExecution.execute_import(project.id, report,
uploads_folder_path: uploads_dir,
default_author: "Imported Author"
)
assert result.success
assert result.tags == %{created: 2, skipped: 0}
assert result.posts == %{imported: 1, skipped: 0, errors: 0}
assert result.pages == %{imported: 1, skipped: 0, errors: 0}
assert result.media == %{imported: 1, skipped: 0, errors: 0}
assert result.errors == []
tag_names = project.id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.sort()
assert tag_names == ["General", "News"]
posts = Repo.all(from post in Posts.Post, where: post.project_id == ^project.id, order_by: [asc: post.slug])
assert Enum.map(posts, & &1.slug) == ["about", "hello-world"]
hello_world = Enum.find(posts, &(&1.slug == "hello-world"))
about = Enum.find(posts, &(&1.slug == "about"))
assert hello_world.status == :published
assert hello_world.author == "Importer"
assert hello_world.content == nil
assert hello_world.file_path != ""
assert File.exists?(Path.join(temp_dir, hello_world.file_path))
assert File.read!(Path.join(temp_dir, hello_world.file_path)) =~ "Hello World"
assert about.status == :published
assert about.content == nil
assert "page" in about.categories
imported_media = Repo.one!(from media in BDS.Media.Media, where: media.project_id == ^project.id)
assert imported_media.original_name == "import-asset.txt"
assert File.exists?(Path.join(temp_dir, imported_media.file_path))
end
test "execute_import skips conflicts by default and can import them with a new slug", %{project: project, temp_dir: temp_dir} do
assert {:ok, _existing_post} =
Posts.create_post(%{
project_id: project.id,
title: "Conflict Me",
content: "Local body",
checksum: sha256("Local body")
})
wxr_path = Path.join(temp_dir, "conflict.xml")
File.write!(wxr_path, conflict_only_wxr_xml())
assert {:ok, report} = ImportAnalysis.analyze_wxr(project.id, wxr_path, nil)
assert {:ok, skipped_result} = ImportExecution.execute_import(project.id, report, default_author: "Imported Author")
assert skipped_result.posts == %{imported: 0, skipped: 1, errors: 0}
assert Repo.aggregate(Posts.Post, :count, :id) == 1
import_report = put_in(report.items.posts, [%{List.first(report.items.posts) | resolution: "import"}])
assert {:ok, imported_result} = ImportExecution.execute_import(project.id, import_report, default_author: "Imported Author")
assert imported_result.posts == %{imported: 1, skipped: 0, errors: 0}
slugs = Repo.all(from post in Posts.Post, where: post.project_id == ^project.id, select: post.slug, order_by: [asc: post.slug])
assert length(slugs) == 2
assert "conflict-me" in slugs
assert Enum.any?(slugs, &(&1 != "conflict-me"))
end
test "execute_import reports phase progress while importing", %{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)
assert {:ok, _result} =
ImportExecution.execute_import(project.id, report,
uploads_folder_path: uploads_dir,
default_author: "Imported Author",
on_progress: fn phase, current, total, detail ->
send(self(), {:execution_progress, phase, current, total, detail})
end
)
assert_received {:execution_progress, "tags", 0, 2, %{detail: "creating_tags"}}
assert_received {:execution_progress, "posts", 0, 1, %{detail: "importing_posts"}}
assert_received {:execution_progress, "media", 0, 1, %{detail: "importing_media"}}
assert_received {:execution_progress, "pages", 0, 1, %{detail: "importing_pages"}}
assert_received {:execution_progress, "complete", 1, 1, %{detail: "import_complete"}}
end
defp sha256(value) do
:sha256
|> :crypto.hash(value)
|> Base.encode16(case: :lower)
end
defp basic_wxr_xml do
"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:wp="http://wordpress.org/export/1.2/">
<channel>
<title>Legacy Blog</title>
<link>https://legacy.example</link>
<description>Imported from the legacy desktop app</description>
<language>en</language>
<wp:category>
<wp:cat_name><![CDATA[General]]></wp:cat_name>
<wp:category_nicename>general</wp:category_nicename>
<wp:category_parent></wp:category_parent>
</wp:category>
<wp:tag>
<wp:tag_slug>news</wp:tag_slug>
<wp:tag_name><![CDATA[News]]></wp:tag_name>
</wp:tag>
<item>
<title>Hello World</title>
<link>https://legacy.example/2024/05/hello-world</link>
<pubDate>Wed, 01 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[<p>Hello world</p>]]></content:encoded>
<excerpt:encoded><![CDATA[Legacy hello]]></excerpt:encoded>
<wp:post_id>101</wp:post_id>
<wp:post_date>2024-05-01 12:00:00</wp:post_date>
<wp:post_modified>2024-05-01 12:30:00</wp:post_modified>
<wp:post_name>hello-world</wp:post_name>
<wp:status>publish</wp:status>
<wp:post_type>post</wp:post_type>
<category domain="category" nicename="general"><![CDATA[General]]></category>
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
</item>
<item>
<title>About</title>
<link>https://legacy.example/about</link>
<pubDate>Thu, 02 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[<p>About page</p>]]></content:encoded>
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
<wp:post_id>201</wp:post_id>
<wp:post_date>2024-05-02 12:00:00</wp:post_date>
<wp:post_modified>2024-05-02 12:30:00</wp:post_modified>
<wp:post_name>about</wp:post_name>
<wp:status>publish</wp:status>
<wp:post_type>page</wp:post_type>
<category domain="category" nicename="general"><![CDATA[General]]></category>
</item>
<item>
<title>Import Asset</title>
<link>https://legacy.example/wp-content/uploads/2024/05/import-asset.txt</link>
<pubDate>Fri, 03 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[Legacy text attachment]]></content:encoded>
<wp:post_id>301</wp:post_id>
<wp:post_parent>101</wp:post_parent>
<wp:post_name>import-asset</wp:post_name>
<wp:status>inherit</wp:status>
<wp:post_type>attachment</wp:post_type>
<wp:attachment_url><![CDATA[https://legacy.example/wp-content/uploads/2024/05/import-asset.txt]]></wp:attachment_url>
</item>
</channel>
</rss>
"""
end
defp conflict_only_wxr_xml do
"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:wp="http://wordpress.org/export/1.2/">
<channel>
<title>Legacy Blog</title>
<link>https://legacy.example</link>
<description>Imported from the legacy desktop app</description>
<language>en</language>
<item>
<title>Conflict Me</title>
<link>https://legacy.example/conflict-me</link>
<pubDate>Thu, 02 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[<p>Incoming conflict body</p>]]></content:encoded>
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
<wp:post_id>402</wp:post_id>
<wp:post_date>2024-05-02 12:00:00</wp:post_date>
<wp:post_modified>2024-05-02 12:30:00</wp:post_modified>
<wp:post_name>conflict-me</wp:post_name>
<wp:status>publish</wp:status>
<wp:post_type>post</wp:post_type>
</item>
</channel>
</rss>
"""
end
end

View File

@@ -0,0 +1,111 @@
defmodule BDS.WxrParserTest do
use ExUnit.Case, async: true
alias BDS.WxrParser
test "parse_xml extracts site info, posts, pages, media, categories, and tags" do
parsed = WxrParser.parse_xml(sample_wxr_xml())
assert parsed.site.title == "Legacy Blog"
assert parsed.site.link == "https://legacy.example"
assert parsed.site.description == "Imported from the legacy desktop app"
assert parsed.site.language == "en"
assert parsed.categories == [%{name: "General", slug: "general", parent: ""}]
assert parsed.tags == [%{name: "News", slug: "news"}]
assert [%{wp_id: 101, title: "Hello World", slug: "hello-world", creator: "Importer", status: "publish", post_type: "post", categories: ["General"], tags: ["News"]}] = parsed.posts
assert [%{wp_id: 201, title: "About", slug: "about", post_type: "page", categories: ["General"], tags: []}] = parsed.pages
assert [media] = parsed.media
assert media.wp_id == 301
assert media.title == "Import Asset"
assert media.filename == "import-asset.txt"
assert media.relative_path == "2024/05/import-asset.txt"
assert media.parent_id == 101
assert media.mime_type == "text/plain"
end
test "parse_xml raises when the WXR file has no channel" do
assert_raise RuntimeError, ~r/no <channel> element found/, fn ->
WxrParser.parse_xml("<rss version=\"2.0\"></rss>")
end
end
defp sample_wxr_xml do
"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:wp="http://wordpress.org/export/1.2/">
<channel>
<title>Legacy Blog</title>
<link>https://legacy.example</link>
<description>Imported from the legacy desktop app</description>
<language>en</language>
<wp:category>
<wp:cat_name><![CDATA[General]]></wp:cat_name>
<wp:category_nicename>general</wp:category_nicename>
<wp:category_parent></wp:category_parent>
</wp:category>
<wp:tag>
<wp:tag_slug>news</wp:tag_slug>
<wp:tag_name><![CDATA[News]]></wp:tag_name>
</wp:tag>
<item>
<title>Hello World</title>
<link>https://legacy.example/2024/05/hello-world</link>
<pubDate>Wed, 01 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[<p>Hello <strong>world</strong>.</p><p>[gallery ids="1,2"]</p>]]></content:encoded>
<excerpt:encoded><![CDATA[Legacy hello]]></excerpt:encoded>
<wp:post_id>101</wp:post_id>
<wp:post_date>2024-05-01 14:00:00</wp:post_date>
<wp:post_modified>2024-05-02 15:00:00</wp:post_modified>
<wp:post_name>hello-world</wp:post_name>
<wp:status>publish</wp:status>
<wp:post_type>post</wp:post_type>
<category domain="category" nicename="general"><![CDATA[General]]></category>
<category domain="post_tag" nicename="news"><![CDATA[News]]></category>
</item>
<item>
<title>About</title>
<link>https://legacy.example/about</link>
<pubDate>Thu, 02 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[<p>About page</p>]]></content:encoded>
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
<wp:post_id>201</wp:post_id>
<wp:post_date>2024-05-02 12:00:00</wp:post_date>
<wp:post_modified>2024-05-02 12:30:00</wp:post_modified>
<wp:post_name>about</wp:post_name>
<wp:status>publish</wp:status>
<wp:post_type>page</wp:post_type>
<category domain="category" nicename="general"><![CDATA[General]]></category>
</item>
<item>
<title>Import Asset</title>
<link>https://legacy.example/wp-content/uploads/2024/05/import-asset.txt</link>
<pubDate>Fri, 03 May 2024 12:00:00 +0000</pubDate>
<dc:creator><![CDATA[Importer]]></dc:creator>
<content:encoded><![CDATA[Legacy text attachment]]></content:encoded>
<wp:post_id>301</wp:post_id>
<wp:post_parent>101</wp:post_parent>
<wp:post_name>import-asset</wp:post_name>
<wp:status>inherit</wp:status>
<wp:post_type>attachment</wp:post_type>
<wp:attachment_url><![CDATA[https://legacy.example/wp-content/uploads/2024/05/import-asset.txt]]></wp:attachment_url>
</item>
</channel>
</rss>
"""
end
end