Compare commits
11 Commits
f88f9dc0fd
...
a7747bd1e1
| Author | SHA1 | Date | |
|---|---|---|---|
| a7747bd1e1 | |||
| fc25154d1c | |||
| 6b7603b1cf | |||
| 9a44e6acc8 | |||
| a80ce7c845 | |||
| 8358f9000e | |||
| a6033cb86a | |||
| f178b5b207 | |||
| 155fda8b81 | |||
| 4ae6c55e83 | |||
| dccb6a8786 |
442
CODESMELL.md
Normal file
442
CODESMELL.md
Normal 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 6–8 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 6–8 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
43
PLAN.md
@@ -4,10 +4,10 @@ This document tracks the current implementation state of bDS2 against the Allium
|
||||
|
||||
## Open Work Summary
|
||||
|
||||
- Completed plan steps: 1, 2, 3, 4, 5, 6, 8.
|
||||
- Open plan steps: 7, 9, 10, 11, 12.
|
||||
- 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.
|
||||
- Scheduled after the current parity pass: 10 menu editor parity, 11 desktop-side CLI mutation watching, 12 import execution/editor parity.
|
||||
- Completed plan steps: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12.
|
||||
- Open plan steps: none.
|
||||
- Next actionable step: rerun parity audits when scope expands; current implemented surfaces are at parity.
|
||||
- Scheduled after the current parity pass: none.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### 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 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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
|
||||
## 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. |
|
||||
| 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. |
|
||||
| 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
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
7. Restore remaining chat editor parity on the implemented route. Completed 2026-04-29.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
9. Fix any remaining batch 3 and batch 4 parity gaps that the audit proves. Completed 2026-04-29.
|
||||
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.
|
||||
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.
|
||||
10. Restore menu editor parity on the implemented data model. Completed 2026-04-29.
|
||||
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.
|
||||
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.
|
||||
11. Restore desktop-side CLI mutation watching parity. Completed 2026-04-29.
|
||||
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.
|
||||
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.
|
||||
12. Restore import execution and editor parity. Completed 2026-04-30.
|
||||
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
|
||||
|
||||
@@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
## Batch 4 Audit Matrix
|
||||
|
||||
162
lib/bds/ai.ex
162
lib/bds/ai.ex
@@ -34,6 +34,17 @@ defmodule BDS.AI do
|
||||
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
|
||||
backend = Keyword.get(opts, :secret_backend, SecretBackend)
|
||||
kind_key = Atom.to_string(kind)
|
||||
@@ -49,6 +60,8 @@ defmodule BDS.AI do
|
||||
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
|
||||
backend = Keyword.get(opts, :secret_backend, SecretBackend)
|
||||
kind_key = Atom.to_string(kind)
|
||||
@@ -67,6 +80,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_endpoint(endpoint_kind()) :: :ok
|
||||
def delete_endpoint(kind) when is_atom(kind) do
|
||||
kind_key = Atom.to_string(kind)
|
||||
delete_setting("ai.#{kind_key}.url")
|
||||
@@ -75,11 +89,15 @@ defmodule BDS.AI do
|
||||
:ok
|
||||
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
|
||||
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)
|
||||
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
|
||||
http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient)
|
||||
|
||||
@@ -111,6 +129,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_catalog_providers() :: [map()]
|
||||
def list_catalog_providers do
|
||||
Repo.all(from provider in CatalogProvider, order_by: [asc: provider.id])
|
||||
|> Enum.map(fn provider ->
|
||||
@@ -126,6 +145,7 @@ defmodule BDS.AI do
|
||||
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
|
||||
query =
|
||||
from model in Model,
|
||||
@@ -144,14 +164,17 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec catalog_meta(String.t()) :: {:ok, String.t() | nil}
|
||||
def catalog_meta(key) when is_binary(key) do
|
||||
{:ok, get_catalog_meta_value(key)}
|
||||
end
|
||||
|
||||
@spec set_airplane_mode(boolean()) :: :ok | {:error, term()}
|
||||
def set_airplane_mode(enabled) when is_boolean(enabled) do
|
||||
put_setting("ai.airplane_mode_enabled", Atom.to_string(enabled))
|
||||
end
|
||||
|
||||
@spec airplane_mode?(boolean()) :: boolean()
|
||||
def airplane_mode?(default \\ false) when is_boolean(default) do
|
||||
case get_setting("ai.airplane_mode_enabled") do
|
||||
nil -> default
|
||||
@@ -160,6 +183,7 @@ defmodule BDS.AI do
|
||||
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
|
||||
case Map.fetch(@model_preference_keys, key) do
|
||||
{:ok, setting_key} -> put_setting(setting_key, model)
|
||||
@@ -167,6 +191,7 @@ defmodule BDS.AI do
|
||||
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
|
||||
case Map.fetch(@model_preference_keys, key) do
|
||||
{:ok, setting_key} -> {:ok, get_setting(setting_key)}
|
||||
@@ -174,6 +199,7 @@ defmodule BDS.AI do
|
||||
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
|
||||
capabilities = %{
|
||||
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))
|
||||
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
|
||||
run_one_shot(
|
||||
:detect_language,
|
||||
@@ -194,6 +221,7 @@ defmodule BDS.AI do
|
||||
)
|
||||
end
|
||||
|
||||
@spec analyze_taxonomy(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def analyze_taxonomy(post_input, opts \\ []) when is_list(opts) do
|
||||
with {:ok, post} <- normalize_post_input(post_input) do
|
||||
run_one_shot(
|
||||
@@ -212,6 +240,42 @@ defmodule BDS.AI do
|
||||
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
|
||||
with {:ok, post} <- normalize_post_input(post_input) do
|
||||
run_one_shot(
|
||||
@@ -231,6 +295,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec translate_post(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def translate_post(post_input, target_language, opts \\ [])
|
||||
when is_binary(target_language) and is_list(opts) do
|
||||
with {:ok, post} <- normalize_post_input(post_input) do
|
||||
@@ -251,6 +316,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def analyze_image(media_input, opts \\ []) when is_list(opts) do
|
||||
with {:ok, media} <- normalize_media_input(media_input),
|
||||
:ok <- ensure_image_media(media) do
|
||||
@@ -271,6 +337,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec translate_media(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def translate_media(media_input, target_language, opts \\ [])
|
||||
when is_binary(target_language) and is_list(opts) do
|
||||
with {:ok, media} <- normalize_media_input(media_input) do
|
||||
@@ -291,6 +358,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
|
||||
def start_chat(attrs \\ %{}) when is_map(attrs) do
|
||||
now = Persistence.now_ms()
|
||||
model = Map.get(attrs, :model) || Map.get(attrs, "model")
|
||||
@@ -312,11 +380,13 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_chat_conversations() :: [map()]
|
||||
def list_chat_conversations do
|
||||
Repo.all(from conversation in ChatConversation, order_by: [desc: conversation.updated_at])
|
||||
|> Enum.map(&format_conversation/1)
|
||||
end
|
||||
|
||||
@spec available_chat_models(String.t() | nil) :: [map()]
|
||||
def available_chat_models(current_model \\ nil) do
|
||||
endpoint_models = configured_chat_models()
|
||||
|
||||
@@ -344,6 +414,8 @@ defmodule BDS.AI do
|
||||
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)
|
||||
when is_binary(conversation_id) and is_binary(model_id) do
|
||||
case Repo.get(ChatConversation, conversation_id) do
|
||||
@@ -361,6 +433,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_chat_messages(String.t()) :: [map()]
|
||||
def list_chat_messages(conversation_id) when is_binary(conversation_id) do
|
||||
Repo.all(
|
||||
from message in ChatMessage,
|
||||
@@ -370,6 +443,8 @@ defmodule BDS.AI do
|
||||
|> Enum.map(&format_chat_message/1)
|
||||
end
|
||||
|
||||
@spec send_chat_message(String.t(), String.t(), keyword()) ::
|
||||
{:ok, map()} | {:error, :not_found | term()}
|
||||
def send_chat_message(conversation_id, content, opts \\ [])
|
||||
when is_binary(conversation_id) and is_binary(content) and is_list(opts) do
|
||||
with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id),
|
||||
@@ -403,6 +478,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec cancel_chat(String.t()) :: :ok
|
||||
def cancel_chat(conversation_id) when is_binary(conversation_id) do
|
||||
case InFlight.lookup(conversation_id) do
|
||||
nil -> :ok
|
||||
@@ -559,7 +635,7 @@ defmodule BDS.AI do
|
||||
defp run_one_shot(operation, payload, opts, formatter) do
|
||||
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),
|
||||
request <- build_one_shot_request(operation, payload, model),
|
||||
{: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}
|
||||
end
|
||||
|
||||
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, _extra) do
|
||||
{:ok, get_model_preference_value(:airplane_image_analysis) || endpoint.model}
|
||||
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do
|
||||
{:ok, Keyword.get(extra, :model) || get_model_preference_value(:airplane_image_analysis) || endpoint.model}
|
||||
end
|
||||
|
||||
defp resolve_model_for_operation(:analyze_image, :online, endpoint, _extra) do
|
||||
{:ok, get_model_preference_value(:image_analysis) || endpoint.model}
|
||||
defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do
|
||||
{:ok, Keyword.get(extra, :model) || get_model_preference_value(:image_analysis) || endpoint.model}
|
||||
end
|
||||
|
||||
defp resolve_model_for_operation(_operation, :airplane, endpoint, _extra) do
|
||||
{:ok, get_model_preference_value(:airplane_title) || endpoint.model}
|
||||
defp resolve_model_for_operation(_operation, :airplane, endpoint, extra) do
|
||||
{:ok, Keyword.get(extra, :model) || get_model_preference_value(:airplane_title) || endpoint.model}
|
||||
end
|
||||
|
||||
defp resolve_model_for_operation(_operation, :online, endpoint, _extra) do
|
||||
{:ok, get_model_preference_value(:title) || endpoint.model}
|
||||
defp resolve_model_for_operation(_operation, :online, endpoint, extra) do
|
||||
{:ok, Keyword.get(extra, :model) || get_model_preference_value(:title) || endpoint.model}
|
||||
end
|
||||
|
||||
defp validate_runtime_target(:analyze_image, model, _mode) do
|
||||
@@ -990,6 +1066,49 @@ defmodule BDS.AI do
|
||||
|> MapSet.size()
|
||||
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
|
||||
"Return JSON with exactly one key: language_code."
|
||||
end
|
||||
@@ -998,6 +1117,10 @@ defmodule BDS.AI do
|
||||
"Return JSON with keys tags and categories, each an array of short strings."
|
||||
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
|
||||
"Return JSON with keys title, excerpt, and slug."
|
||||
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)}"
|
||||
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
|
||||
"Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
||||
end
|
||||
|
||||
@@ -17,7 +17,7 @@ defmodule BDS.Application do
|
||||
|
||||
def desktop_children(_env) 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
|
||||
[]
|
||||
end
|
||||
|
||||
73
lib/bds/cli_sync/watcher.ex
Normal file
73
lib/bds/cli_sync/watcher.ex
Normal 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
|
||||
@@ -243,12 +243,16 @@ defmodule BDS.Desktop.Automation do
|
||||
{messages, buffer} = split_driver_buffer(state.driver_buffer)
|
||||
|
||||
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}}
|
||||
|
||||
{:ok, decoded} ->
|
||||
case matcher.(decoded) do
|
||||
{:ok, reply} -> {:halt, {acc, reply}}
|
||||
:continue -> {:cont, {acc, nil}}
|
||||
end
|
||||
end
|
||||
end) do
|
||||
{state, nil} ->
|
||||
remaining = max(deadline - System.monotonic_time(:millisecond), 0)
|
||||
@@ -282,6 +286,24 @@ defmodule BDS.Desktop.Automation do
|
||||
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
|
||||
deadline = System.monotonic_time(:millisecond) + @ready_timeout
|
||||
do_wait_for_server(base_url, deadline)
|
||||
|
||||
@@ -6,8 +6,9 @@ defmodule BDS.Desktop.ShellLive do
|
||||
import Phoenix.HTML
|
||||
|
||||
alias BDS.AI
|
||||
alias BDS.CliSync.Watcher
|
||||
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.PostEditor
|
||||
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
|
||||
@@ -47,6 +48,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
connected = connected?(socket)
|
||||
|
||||
if connected do
|
||||
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
|
||||
:timer.send_interval(@refresh_interval, :refresh_task_status)
|
||||
end
|
||||
|
||||
@@ -103,6 +105,14 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> assign(:chat_editor_surface_data, %{})
|
||||
|> assign(:chat_editor_surface_tabs, %{})
|
||||
|> 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_git_selected_files, %{})
|
||||
|> 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)}
|
||||
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
|
||||
{:noreply, TagsEditor.toggle_selection(socket, tag_name, &reload_shell/2)}
|
||||
end
|
||||
@@ -725,6 +775,58 @@ defmodule BDS.Desktop.ShellLive do
|
||||
{:noreply, handle_chat_surface_action(socket, params)}
|
||||
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
|
||||
case MiscEditor.rerun(socket) do
|
||||
{:command, action} -> {:noreply, apply_shell_command(socket, action)}
|
||||
@@ -1098,19 +1200,46 @@ defmodule BDS.Desktop.ShellLive do
|
||||
@impl true
|
||||
def handle_info({ref, result}, socket) when is_reference(ref) do
|
||||
Process.demonitor(ref, [:flush])
|
||||
|
||||
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
|
||||
|
||||
def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do
|
||||
next_socket =
|
||||
cond do
|
||||
Map.has_key?(socket.assigns.import_editor_analysis_task_refs, ref) ->
|
||||
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
|
||||
|
||||
{:noreply, next_socket}
|
||||
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
|
||||
{:noreply, ChatEditor.note_tool_call(socket, conversation_id, tool_call, &reload_shell/2)}
|
||||
end
|
||||
@@ -1123,6 +1252,10 @@ defmodule BDS.Desktop.ShellLive do
|
||||
{:noreply, ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)}
|
||||
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
|
||||
raw_task_status = BDS.Tasks.status_snapshot()
|
||||
|
||||
@@ -1205,12 +1338,125 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> assign_post_editor()
|
||||
|> assign_media_editor()
|
||||
|> assign_settings_editor()
|
||||
|> assign_menu_editor()
|
||||
|> assign_tags_editor()
|
||||
|> assign_code_entity_editor()
|
||||
|> assign_chat_editor()
|
||||
|> assign_import_editor()
|
||||
|> assign_misc_editor()
|
||||
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
|
||||
case assigns.workbench.panel.active_tab do
|
||||
:tasks -> render_task_entries(assigns)
|
||||
@@ -1444,6 +1690,10 @@ defmodule BDS.Desktop.ShellLive do
|
||||
SettingsEditor.assign_socket(socket)
|
||||
end
|
||||
|
||||
defp assign_menu_editor(socket) do
|
||||
MenuEditor.assign_socket(socket)
|
||||
end
|
||||
|
||||
defp assign_tags_editor(socket) do
|
||||
TagsEditor.assign_socket(socket)
|
||||
end
|
||||
@@ -1456,6 +1706,10 @@ defmodule BDS.Desktop.ShellLive do
|
||||
ChatEditor.assign_socket(socket)
|
||||
end
|
||||
|
||||
defp assign_import_editor(socket) do
|
||||
ImportEditor.assign_socket(socket)
|
||||
end
|
||||
|
||||
defp assign_misc_editor(socket) do
|
||||
MiscEditor.assign_socket(socket)
|
||||
end
|
||||
|
||||
@@ -915,7 +915,6 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
|
||||
defp blank?(nil), do: true
|
||||
defp blank?(_value), do: false
|
||||
|
||||
defp rewrite_external_images(html) do
|
||||
html =
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
<% end %>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
|
||||
|
||||
1436
lib/bds/desktop/shell_live/import_editor.ex
Normal file
1436
lib/bds/desktop/shell_live/import_editor.ex
Normal file
File diff suppressed because it is too large
Load Diff
@@ -394,6 +394,9 @@
|
||||
<% @current_tab.type == :style and @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 -> %>
|
||||
<TagsEditor.tags_editor tags_editor={@tags_editor} />
|
||||
|
||||
@@ -406,6 +409,9 @@
|
||||
<% @current_tab.type == :chat and @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 -> %>
|
||||
<MiscEditor.misc_editor misc_editor={@misc_editor} />
|
||||
|
||||
|
||||
871
lib/bds/desktop/shell_live/menu_editor.ex
Normal file
871
lib/bds/desktop/shell_live/menu_editor.ex
Normal 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
|
||||
@@ -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>
|
||||
@@ -9,7 +9,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
alias BDS.{I18n, Metadata, Repo}
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Media.Translation, as: MediaTranslation
|
||||
alias BDS.Posts.{Post, Translation}
|
||||
alias BDS.Posts.{Post, PostMedia, Translation}
|
||||
alias BDS.Tags.Tag
|
||||
|
||||
embed_templates "overlay_html/*"
|
||||
@@ -112,10 +112,12 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
end
|
||||
|
||||
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
|
||||
{:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end)
|
||||
_other -> []
|
||||
end
|
||||
Repo.all(
|
||||
from pm in PostMedia,
|
||||
where: pm.post_id == ^post_id,
|
||||
order_by: [asc: pm.sort_order, asc: pm.media_id],
|
||||
select: pm.media_id
|
||||
)
|
||||
rescue
|
||||
_error -> []
|
||||
end
|
||||
@@ -229,10 +231,15 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
end
|
||||
|
||||
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
|
||||
{:ok, %{rows: rows}} -> Enum.map(rows, fn [title] -> title || media_id end)
|
||||
_other -> []
|
||||
end
|
||||
Repo.all(
|
||||
from post in Post,
|
||||
join: pm in PostMedia,
|
||||
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),
|
||||
|
||||
@@ -8,7 +8,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.{AI, I18n, Metadata, PostLinks, Posts, Preview, Repo, Tags, Templates}
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Posts.{Post, Translation}
|
||||
alias BDS.Posts.{Post, PostMedia, Translation}
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
embed_templates "post_editor_html/*"
|
||||
@@ -770,9 +770,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
end
|
||||
|
||||
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
|
||||
{:ok, %{rows: rows}} ->
|
||||
Enum.map(rows, fn [media_id, sort_order] ->
|
||||
rows =
|
||||
Repo.all(
|
||||
from pm in PostMedia,
|
||||
where: pm.post_id == ^post_id,
|
||||
order_by: [asc: pm.sort_order, asc: pm.media_id],
|
||||
select: {pm.media_id, pm.sort_order}
|
||||
)
|
||||
|
||||
Enum.map(rows, fn {media_id, sort_order} ->
|
||||
case Repo.get(Media, media_id) do
|
||||
%Media{} = media ->
|
||||
%{
|
||||
@@ -787,10 +793,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
_other ->
|
||||
[]
|
||||
end
|
||||
rescue
|
||||
_error -> []
|
||||
end
|
||||
|
||||
@@ -2,22 +2,49 @@ defmodule BDS.Generation do
|
||||
@moduledoc false
|
||||
|
||||
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.Frontmatter
|
||||
alias BDS.Generation.GeneratedFileHash
|
||||
alias BDS.Generation.Paths
|
||||
alias BDS.Metadata
|
||||
alias BDS.Persistence
|
||||
alias BDS.PreviewAssets
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Posts.Translation
|
||||
alias BDS.Projects
|
||||
alias BDS.Rendering
|
||||
alias BDS.Repo
|
||||
alias BDS.Slug
|
||||
|
||||
@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])
|
||||
when is_binary(project_id) and is_list(sections) do
|
||||
project = Projects.get_project!(project_id)
|
||||
@@ -40,13 +67,15 @@ defmodule BDS.Generation do
|
||||
}}
|
||||
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, opts)
|
||||
when is_binary(project_id) and is_list(sections) and is_list(opts) do
|
||||
with {:ok, plan} <- plan_generation(project_id, sections) do
|
||||
outputs = build_outputs(plan)
|
||||
on_progress = progress_callback(opts)
|
||||
on_progress = callback(opts)
|
||||
total_outputs = length(outputs)
|
||||
|
||||
:ok = report_generation_started(on_progress, total_outputs, "generated files")
|
||||
@@ -63,11 +92,13 @@ defmodule BDS.Generation do
|
||||
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, opts) when is_binary(project_id) and is_list(sections) and is_list(opts) 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...")
|
||||
|
||||
data =
|
||||
@@ -128,67 +159,7 @@ defmodule BDS.Generation do
|
||||
end
|
||||
end
|
||||
|
||||
defp progress_callback(opts) do
|
||||
case Keyword.get(opts, :on_progress) do
|
||||
callback when is_function(callback, 2) -> callback
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp report_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
|
||||
|
||||
@spec apply_validation(String.t(), [section()] | map()) :: {:ok, map()} | {:error, term()}
|
||||
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
|
||||
expected_outputs = build_outputs(plan)
|
||||
@@ -283,26 +254,20 @@ defmodule BDS.Generation do
|
||||
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
|
||||
{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")
|
||||
@spec post_output_path(map(), String.t() | nil) :: String.t()
|
||||
defdelegate post_output_path(post, language), to: Paths
|
||||
|
||||
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
|
||||
@typedoc "Result returned by `write_generated_file/3,4`."
|
||||
@type write_result :: %{relative_path: String.t(), content_hash: String.t(), written?: boolean()}
|
||||
|
||||
@spec write_generated_file(String.t(), String.t(), String.t()) :: {:ok, write_result()}
|
||||
def 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)
|
||||
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)
|
||||
@@ -335,6 +300,7 @@ defmodule BDS.Generation do
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_generated_files(String.t()) :: {:ok, [map()]}
|
||||
def list_generated_files(project_id) when is_binary(project_id) do
|
||||
{:ok,
|
||||
Repo.all(
|
||||
@@ -344,6 +310,7 @@ defmodule BDS.Generation do
|
||||
)}
|
||||
end
|
||||
|
||||
@spec delete_generated_file(String.t(), String.t()) :: :ok | {:error, term()}
|
||||
def delete_generated_file(project_id, relative_path)
|
||||
when is_binary(project_id) and is_binary(relative_path) do
|
||||
project = Projects.get_project!(project_id)
|
||||
@@ -737,14 +704,14 @@ defmodule BDS.Generation do
|
||||
|
||||
sitemap =
|
||||
if :core in plan.sections do
|
||||
[{"sitemap.xml", render_sitemap(urls)}]
|
||||
[{"sitemap.xml", render(urls)}]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
pagefind_outputs =
|
||||
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
|
||||
[]
|
||||
end
|
||||
@@ -800,7 +767,7 @@ defmodule BDS.Generation do
|
||||
sitemap_content =
|
||||
main_paths
|
||||
|> Enum.map(&url_for_output(plan.base_url, &1))
|
||||
|> render_sitemap()
|
||||
|> render()
|
||||
|
||||
additional_expected_paths =
|
||||
additional_language_sets
|
||||
@@ -823,7 +790,7 @@ defmodule BDS.Generation do
|
||||
[] -> sitemap_content
|
||||
|
||||
languages ->
|
||||
render_multi_language_sitemap(
|
||||
render_multi_language(
|
||||
plan,
|
||||
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))),
|
||||
@@ -960,8 +927,6 @@ defmodule BDS.Generation do
|
||||
end)
|
||||
end
|
||||
|
||||
defp truthy_flag?(value), do: value not in [false, nil]
|
||||
|
||||
defp disk_generated_files(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
html_root = output_path(project, "")
|
||||
@@ -1294,91 +1259,6 @@ defmodule BDS.Generation do
|
||||
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(
|
||||
project_id,
|
||||
main_language,
|
||||
@@ -1453,35 +1333,6 @@ defmodule BDS.Generation do
|
||||
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
|
||||
Map.new(published_translations, fn translation ->
|
||||
{{translation.translation_for, translation.language}, translation}
|
||||
@@ -1526,519 +1377,6 @@ defmodule BDS.Generation do
|
||||
}
|
||||
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("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
|> String.replace("\"", """)
|
||||
|> String.replace("'", "'")
|
||||
end
|
||||
|
||||
defp upsert_generated_file_hash(project_id, relative_path, content_hash, now) do
|
||||
%GeneratedFileHash{}
|
||||
|> GeneratedFileHash.changeset(%{
|
||||
@@ -2107,8 +1445,8 @@ defmodule BDS.Generation do
|
||||
|
||||
expected_path_set =
|
||||
params.sitemap_xml
|
||||
|> extract_sitemap_locs()
|
||||
|> Enum.map(&sitemap_loc_to_project_path(&1, params.base_url))
|
||||
|> extract_locs()
|
||||
|> Enum.map(&loc_to_project_path(&1, params.base_url))
|
||||
|> Enum.reduce(MapSet.new(), &MapSet.put(&2, normalize_url_path(&1)))
|
||||
|> then(fn expected_paths ->
|
||||
Enum.reduce(Map.get(params, :additional_expected_paths, []), expected_paths, fn path, acc ->
|
||||
@@ -2190,34 +1528,6 @@ defmodule BDS.Generation do
|
||||
}
|
||||
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
|
||||
index_paths
|
||||
|> Enum.with_index(1)
|
||||
@@ -2243,56 +1553,6 @@ defmodule BDS.Generation do
|
||||
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
|
||||
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
|
||||
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
|
||||
"#{year}/#{String.pad_leading(Integer.to_string(month), 2, "0")}/#{String.pad_leading(Integer.to_string(day), 2, "0")}/#{slug}"
|
||||
end
|
||||
|
||||
70
lib/bds/generation/pagefind.ex
Normal file
70
lib/bds/generation/pagefind.ex
Normal 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
262
lib/bds/generation/paths.ex
Normal 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
|
||||
96
lib/bds/generation/progress.ex
Normal file
96
lib/bds/generation/progress.ex
Normal 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
|
||||
227
lib/bds/generation/renderers.ex
Normal file
227
lib/bds/generation/renderers.ex
Normal 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
|
||||
280
lib/bds/generation/sitemap.ex
Normal file
280
lib/bds/generation/sitemap.ex
Normal 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("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
|> String.replace("\"", """)
|
||||
|> String.replace("'", "'")
|
||||
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
479
lib/bds/import_analysis.ex
Normal 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
|
||||
@@ -17,13 +17,60 @@ defmodule BDS.ImportDefinitions do
|
||||
name: attr(attrs, :name) || "",
|
||||
wxr_file_path: attr(attrs, :wxr_file_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,
|
||||
updated_at: now
|
||||
})
|
||||
|> Repo.insert()
|
||||
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
|
||||
Repo.all(
|
||||
from definition in ImportDefinition,
|
||||
@@ -34,4 +81,23 @@ defmodule BDS.ImportDefinitions do
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
489
lib/bds/import_execution.ex
Normal file
489
lib/bds/import_execution.ex
Normal 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
|
||||
@@ -21,6 +21,13 @@ defmodule BDS.MCP do
|
||||
@page_size 50
|
||||
@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
|
||||
[
|
||||
tool("check_term", true),
|
||||
@@ -37,6 +44,7 @@ defmodule BDS.MCP do
|
||||
]
|
||||
end
|
||||
|
||||
@spec list_resources() :: [resource_descriptor()]
|
||||
def list_resources do
|
||||
[
|
||||
%{name: "posts", uri: "bds://posts"},
|
||||
@@ -46,6 +54,7 @@ defmodule BDS.MCP do
|
||||
]
|
||||
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
|
||||
ProposalStore.ensure_started()
|
||||
|
||||
@@ -65,6 +74,7 @@ defmodule BDS.MCP do
|
||||
end
|
||||
end
|
||||
|
||||
@spec read_resource(String.t()) :: {:ok, term()} | {:error, term()}
|
||||
def read_resource(uri) when is_binary(uri) do
|
||||
ProposalStore.ensure_started()
|
||||
|
||||
@@ -79,6 +89,7 @@ defmodule BDS.MCP do
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
|
||||
def validate_template(source) when is_binary(source) do
|
||||
case Liquex.parse(source) do
|
||||
{:ok, _ast} -> {:ok, %{valid: true, errors: []}}
|
||||
@@ -342,10 +353,10 @@ defmodule BDS.MCP do
|
||||
proposal.data["template_id"] |> Templates.publish_template()
|
||||
|
||||
"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" ->
|
||||
Posts.update_post(proposal.data["post_id"], atomize_keys(proposal.data["changes"]))
|
||||
Posts.update_post(proposal.data["post_id"], proposal.data["changes"] || %{})
|
||||
|
||||
_other ->
|
||||
{:error, :unsupported_proposal}
|
||||
@@ -635,10 +646,6 @@ defmodule BDS.MCP do
|
||||
defp parse_template_kind("not_found"), do: :not_found
|
||||
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
|
||||
struct
|
||||
|> Map.from_struct()
|
||||
|
||||
206
lib/bds/media.ex
206
lib/bds/media.ex
@@ -7,12 +7,20 @@ defmodule BDS.Media do
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Media.Translation
|
||||
alias BDS.Persistence
|
||||
alias BDS.Posts.PostMedia
|
||||
alias BDS.Projects
|
||||
alias BDS.Rebuild
|
||||
alias BDS.Repo
|
||||
alias BDS.Search
|
||||
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
|
||||
project = Projects.get_project!(attr(attrs, :project_id))
|
||||
source_path = attr(attrs, :source_path)
|
||||
@@ -26,8 +34,10 @@ defmodule BDS.Media do
|
||||
destination = Path.join(Projects.project_data_dir(project), file_path)
|
||||
stat = File.stat!(source_path)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
media =
|
||||
:ok = File.mkdir_p(Path.dirname(destination))
|
||||
:ok = File.cp(source_path, destination)
|
||||
|
||||
case Repo.transaction(fn ->
|
||||
%Media{}
|
||||
|> Media.changeset(%{
|
||||
id: Ecto.UUID.generate(),
|
||||
@@ -51,20 +61,21 @@ defmodule BDS.Media do
|
||||
updated_at: now
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
:ok = File.mkdir_p(Path.dirname(destination))
|
||||
:ok = File.cp(source_path, destination)
|
||||
end) do
|
||||
{:ok, media} ->
|
||||
:ok = write_sidecar(project, media)
|
||||
:ok = ensure_thumbnails(project, media)
|
||||
:ok = Search.sync_media(media)
|
||||
media
|
||||
end)
|
||||
|> case do
|
||||
{:ok, media} -> {:ok, media}
|
||||
{:error, reason} -> {:error, reason}
|
||||
{:ok, media}
|
||||
|
||||
{:error, reason} ->
|
||||
_ = File.rm(destination)
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@spec update_media(String.t(), attrs()) ::
|
||||
{:ok, Media.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def update_media(media_id, attrs) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -85,23 +96,23 @@ defmodule BDS.Media do
|
||||
|
||||
project = Projects.get_project!(media.project_id)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
updated_media =
|
||||
case Repo.transaction(fn ->
|
||||
media
|
||||
|> Media.changeset(updates)
|
||||
|> Repo.update!()
|
||||
|
||||
end) do
|
||||
{:ok, updated_media} ->
|
||||
:ok = write_sidecar(project, updated_media)
|
||||
:ok = Search.sync_media(updated_media)
|
||||
updated_media
|
||||
end)
|
||||
|> case do
|
||||
{:ok, updated_media} -> {:ok, updated_media}
|
||||
{:error, reason} -> {:error, reason}
|
||||
{:ok, updated_media}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec sync_media_sidecar(String.t()) :: :ok | {:error, :not_found | term()}
|
||||
def sync_media_sidecar(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -114,6 +125,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec sync_media_from_sidecar(String.t()) ::
|
||||
{:ok, Media.t()} | {:error, :not_found | term()}
|
||||
def sync_media_from_sidecar(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -131,6 +144,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec sync_media_translation_sidecar(String.t()) ::
|
||||
{:ok, Translation.t()} | {:error, :not_found | term()}
|
||||
def sync_media_translation_sidecar(translation_id) do
|
||||
case Repo.get(Translation, translation_id) do
|
||||
nil ->
|
||||
@@ -144,6 +159,8 @@ defmodule BDS.Media do
|
||||
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
|
||||
case Repo.get(Translation, translation_id) do
|
||||
nil ->
|
||||
@@ -171,6 +188,8 @@ defmodule BDS.Media do
|
||||
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
|
||||
project = Projects.get_project!(project_id)
|
||||
sidecar_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||
@@ -182,6 +201,8 @@ defmodule BDS.Media do
|
||||
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
|
||||
project = Projects.get_project!(project_id)
|
||||
sidecar_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||
@@ -214,6 +235,7 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_media(String.t()) :: {:ok, :deleted} | {:error, :not_found}
|
||||
def delete_media(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -244,6 +266,8 @@ defmodule BDS.Media do
|
||||
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
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -269,23 +293,24 @@ defmodule BDS.Media do
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
Repo.transaction(fn ->
|
||||
updated_translation =
|
||||
case Repo.transaction(fn ->
|
||||
translation
|
||||
|> Translation.changeset(translation_attrs)
|
||||
|> Repo.insert_or_update!()
|
||||
|
||||
end) do
|
||||
{:ok, updated_translation} ->
|
||||
:ok = write_translation_sidecar(project, media, updated_translation)
|
||||
:ok = Search.sync_media(media.id)
|
||||
updated_translation
|
||||
end)
|
||||
|> case do
|
||||
{:ok, updated_translation} -> {:ok, updated_translation}
|
||||
{:error, reason} -> {:error, reason}
|
||||
{:ok, updated_translation}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
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
|
||||
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
||||
|
||||
@@ -301,21 +326,22 @@ defmodule BDS.Media do
|
||||
translation ->
|
||||
project = Projects.get_project!(media.project_id)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
Repo.delete!(translation)
|
||||
case Repo.transaction(fn -> Repo.delete!(translation) end) do
|
||||
{:ok, _deleted} ->
|
||||
delete_file_if_present(media.project_id, translation_sidecar_path(media, normalized_language))
|
||||
:ok = Search.sync_media(media)
|
||||
:ok = write_sidecar(project, media)
|
||||
true
|
||||
end)
|
||||
|> case do
|
||||
{:ok, deleted?} -> {:ok, deleted?}
|
||||
{:error, reason} -> {:error, reason}
|
||||
{:ok, true}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
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
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -334,11 +360,11 @@ defmodule BDS.Media do
|
||||
else
|
||||
mime_type = media.mime_type || detect_mime(media.original_name || media.filename)
|
||||
{width, height} = image_dimensions(new_source_path, mime_type)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
previous_destination_backup = destination <> ".bak"
|
||||
_ = File.rename(destination, previous_destination_backup)
|
||||
:ok = File.cp(new_source_path, destination)
|
||||
|
||||
updated_media =
|
||||
case Repo.transaction(fn ->
|
||||
media
|
||||
|> Media.changeset(%{
|
||||
size: stat.size,
|
||||
@@ -348,21 +374,24 @@ defmodule BDS.Media do
|
||||
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)
|
||||
updated_media
|
||||
end)
|
||||
|> case do
|
||||
{:ok, updated_media} -> {:ok, updated_media}
|
||||
{:error, reason} -> {:error, reason}
|
||||
{:ok, updated_media}
|
||||
|
||||
{:error, reason} ->
|
||||
_ = File.rename(previous_destination_backup, destination)
|
||||
{:error, reason}
|
||||
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
|
||||
Repo.all(
|
||||
from translation in Translation,
|
||||
@@ -371,21 +400,24 @@ defmodule BDS.Media do
|
||||
)
|
||||
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
|
||||
Repo.all(
|
||||
from post in BDS.Posts.Post,
|
||||
join: post_media in "post_media",
|
||||
on: post_media.post_id == post.id,
|
||||
where: post_media.media_id == ^media_id,
|
||||
order_by: [asc: post_media.sort_order, asc: post.updated_at],
|
||||
join: pm in PostMedia,
|
||||
on: pm.post_id == post.id,
|
||||
where: pm.media_id == ^media_id,
|
||||
order_by: [asc: pm.sort_order, asc: post.updated_at],
|
||||
select: %{
|
||||
post_id: post.id,
|
||||
title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id),
|
||||
sort_order: post_media.sort_order
|
||||
sort_order: pm.sort_order
|
||||
}
|
||||
)
|
||||
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
|
||||
case {Repo.get(Media, media_id), Repo.get(BDS.Posts.Post, post_id)} do
|
||||
{nil, _post} ->
|
||||
@@ -397,33 +429,38 @@ defmodule BDS.Media do
|
||||
{%Media{} = media, %BDS.Posts.Post{} = post} ->
|
||||
project = Projects.get_project!(media.project_id)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
case Repo.query("SELECT 1 FROM post_media WHERE post_id = ? AND media_id = ? LIMIT 1", [post.id, media.id]) do
|
||||
{:ok, %{rows: [[1]]}} ->
|
||||
case Repo.transaction(fn ->
|
||||
if Repo.exists?(from pm in PostMedia, where: pm.post_id == ^post.id and pm.media_id == ^media.id) do
|
||||
:already_linked
|
||||
|
||||
_other ->
|
||||
else
|
||||
sort_order = next_sort_order(media.id)
|
||||
|
||||
{:ok, _result} =
|
||||
Repo.query(
|
||||
"INSERT INTO post_media (id, project_id, post_id, media_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[Ecto.UUID.generate(), media.project_id, post.id, media.id, sort_order, Persistence.now_ms()]
|
||||
)
|
||||
%PostMedia{}
|
||||
|> 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!()
|
||||
|
||||
:linked
|
||||
end
|
||||
|
||||
end) do
|
||||
{:ok, _result} ->
|
||||
:ok = write_sidecar(project, media)
|
||||
:ok
|
||||
end)
|
||||
|> case do
|
||||
{:ok, :ok} -> {:ok, :linked}
|
||||
{:error, reason} -> {:error, reason}
|
||||
{:ok, :linked}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
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
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -432,18 +469,25 @@ defmodule BDS.Media do
|
||||
%Media{} = media ->
|
||||
project = Projects.get_project!(media.project_id)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
{:ok, _result} = Repo.query("DELETE FROM post_media WHERE media_id = ? AND post_id = ?", [media.id, post_id])
|
||||
:ok = write_sidecar(project, media)
|
||||
case Repo.transaction(fn ->
|
||||
{_count, _} =
|
||||
Repo.delete_all(
|
||||
from pm in PostMedia, where: pm.media_id == ^media.id and pm.post_id == ^post_id
|
||||
)
|
||||
|
||||
:ok
|
||||
end)
|
||||
|> case do
|
||||
{:ok, :ok} -> {:ok, :unlinked}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end) do
|
||||
{:ok, :ok} ->
|
||||
:ok = write_sidecar(project, media)
|
||||
{:ok, :unlinked}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec thumbnail_paths(Media.t()) :: %{required(atom()) => String.t()}
|
||||
def thumbnail_paths(%Media{id: id}) do
|
||||
prefix = String.slice(id, 0, 2)
|
||||
|
||||
@@ -455,6 +499,7 @@ defmodule BDS.Media do
|
||||
}
|
||||
end
|
||||
|
||||
@spec regenerate_thumbnails(String.t()) :: {:ok, Media.t()} | {:error, :not_found | term()}
|
||||
def regenerate_thumbnails(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -467,6 +512,8 @@ defmodule BDS.Media do
|
||||
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
|
||||
project = Projects.get_project!(project_id)
|
||||
on_progress = progress_callback(opts)
|
||||
@@ -518,6 +565,7 @@ defmodule BDS.Media do
|
||||
end)
|
||||
end
|
||||
|
||||
@spec rebuild_media_from_files(String.t(), rebuild_opts()) :: {:ok, [Media.t()]}
|
||||
def rebuild_media_from_files(project_id, opts \\ []) do
|
||||
project = Projects.get_project!(project_id)
|
||||
on_progress = progress_callback(opts)
|
||||
@@ -866,15 +914,21 @@ defmodule BDS.Media do
|
||||
end
|
||||
|
||||
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
|
||||
{:ok, %{rows: rows}} -> Enum.map(rows, fn [post_id] -> post_id end)
|
||||
{:error, _reason} -> []
|
||||
end
|
||||
Repo.all(
|
||||
from pm in PostMedia,
|
||||
where: pm.media_id == ^media_id,
|
||||
order_by: [asc: pm.sort_order, asc: pm.post_id],
|
||||
select: pm.post_id
|
||||
)
|
||||
end
|
||||
|
||||
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
|
||||
{:ok, %{rows: [[value]]}} when is_integer(value) -> value + 1
|
||||
case Repo.one(
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,6 +9,29 @@ defmodule BDS.Media.Media do
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@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
|
||||
belongs_to :project, BDS.Projects.Project, type: :string
|
||||
|
||||
@@ -31,6 +54,7 @@ defmodule BDS.Media.Media do
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(media, attrs) do
|
||||
media
|
||||
|> cast(
|
||||
|
||||
@@ -7,6 +7,19 @@ defmodule BDS.Media.Translation do
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@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
|
||||
belongs_to :media, BDS.Media.Media,
|
||||
foreign_key: :translation_for,
|
||||
@@ -22,6 +35,7 @@ defmodule BDS.Media.Translation do
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(translation, attrs) do
|
||||
translation
|
||||
|> cast(
|
||||
|
||||
@@ -36,16 +36,26 @@ defmodule BDS.Metadata do
|
||||
"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
|
||||
project = Projects.get_project!(project_id)
|
||||
{:ok, load_state(project)}
|
||||
end
|
||||
|
||||
@spec read_project_metadata_from_filesystem(String.t()) :: {:ok, metadata_state()}
|
||||
def read_project_metadata_from_filesystem(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
{:ok, load_state_from_filesystem(project)}
|
||||
end
|
||||
|
||||
@spec update_project_metadata(String.t(), attrs()) ::
|
||||
{:ok, metadata_state()} | {:error, term()}
|
||||
def update_project_metadata(project_id, attrs) do
|
||||
project = Projects.get_project!(project_id)
|
||||
state = load_state(project)
|
||||
@@ -85,6 +95,7 @@ defmodule BDS.Metadata do
|
||||
|> maybe_backfill_embeddings(project_id, state, project_metadata)
|
||||
end
|
||||
|
||||
@spec add_category(String.t(), String.t()) :: {:ok, metadata_state()} | {:error, term()}
|
||||
def add_category(project_id, name) do
|
||||
update_state(project_id, fn project, state, now ->
|
||||
categories =
|
||||
@@ -100,6 +111,7 @@ defmodule BDS.Metadata do
|
||||
end)
|
||||
end
|
||||
|
||||
@spec remove_category(String.t(), String.t()) :: {:ok, metadata_state()} | {:error, term()}
|
||||
def remove_category(project_id, name) do
|
||||
update_state(project_id, fn project, state, now ->
|
||||
categories = Enum.reject(state.categories, &(&1 == name))
|
||||
@@ -113,6 +125,8 @@ defmodule BDS.Metadata do
|
||||
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
|
||||
update_state(project_id, fn project, state, now ->
|
||||
normalized = normalize_category_settings(settings)
|
||||
@@ -124,6 +138,8 @@ defmodule BDS.Metadata do
|
||||
end)
|
||||
end
|
||||
|
||||
@spec set_publishing_preferences(String.t(), map()) ::
|
||||
{:ok, metadata_state()} | {:error, term()}
|
||||
def set_publishing_preferences(project_id, prefs) do
|
||||
update_state(project_id, fn project, state, now ->
|
||||
publishing_preferences = normalize_publishing_preferences(prefs)
|
||||
@@ -133,6 +149,8 @@ defmodule BDS.Metadata do
|
||||
end)
|
||||
end
|
||||
|
||||
@spec sync_project_metadata_from_filesystem(String.t()) ::
|
||||
{:ok, metadata_state()} | {:error, term()}
|
||||
def sync_project_metadata_from_filesystem(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
now = Persistence.now_ms()
|
||||
@@ -167,6 +185,7 @@ defmodule BDS.Metadata do
|
||||
|> unwrap_transaction()
|
||||
end
|
||||
|
||||
@spec flush_project_metadata_to_filesystem(String.t()) :: {:ok, metadata_state()}
|
||||
def flush_project_metadata_to_filesystem(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
state = load_state(project)
|
||||
|
||||
@@ -13,6 +13,7 @@ defmodule BDS.Posts do
|
||||
alias BDS.PostLinks
|
||||
alias BDS.Posts.Link
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Posts.PostMedia
|
||||
alias BDS.Posts.Translation
|
||||
alias BDS.Projects
|
||||
alias BDS.Rebuild
|
||||
@@ -21,6 +22,35 @@ defmodule BDS.Posts do
|
||||
alias BDS.Slug
|
||||
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
|
||||
now = Persistence.now_ms()
|
||||
project_id = attr(attrs, :project_id)
|
||||
@@ -66,6 +96,8 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec update_post(String.t(), attrs()) ::
|
||||
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def update_post(post_id, attrs) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -107,6 +139,8 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec publish_post(String.t()) ::
|
||||
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def publish_post(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -149,6 +183,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec rebuild_posts_from_files(String.t(), rebuild_opts()) :: {:ok, [Post.t()]}
|
||||
def rebuild_posts_from_files(project_id, opts \\ []) do
|
||||
project = Projects.get_project!(project_id)
|
||||
on_progress = progress_callback(opts)
|
||||
@@ -200,6 +235,8 @@ defmodule BDS.Posts do
|
||||
{:ok, posts}
|
||||
end
|
||||
|
||||
@spec discard_post_changes(String.t()) ::
|
||||
{:ok, Post.t()} | {:error, :not_found}
|
||||
def discard_post_changes(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -222,6 +259,7 @@ defmodule BDS.Posts do
|
||||
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{project_id: project_id, file_path: file_path})
|
||||
@@ -246,6 +284,7 @@ defmodule BDS.Posts 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
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -268,6 +307,8 @@ defmodule BDS.Posts do
|
||||
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
|
||||
case Repo.get(Translation, translation_id) do
|
||||
nil ->
|
||||
@@ -289,6 +330,8 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec rewrite_published_post_translation(String.t()) ::
|
||||
{:ok, Translation.t()} | {:error, :not_found}
|
||||
def rewrite_published_post_translation(translation_id) do
|
||||
case Repo.get(Translation, translation_id) do
|
||||
nil ->
|
||||
@@ -305,6 +348,8 @@ defmodule BDS.Posts do
|
||||
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
|
||||
project = Projects.get_project!(project_id)
|
||||
full_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||
@@ -327,6 +372,8 @@ defmodule BDS.Posts do
|
||||
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
|
||||
project = Projects.get_project!(project_id)
|
||||
full_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||
@@ -359,6 +406,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_post(String.t()) :: {:ok, :deleted} | {:error, :not_found}
|
||||
def delete_post(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -376,6 +424,8 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec archive_post(String.t()) ::
|
||||
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def archive_post(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -402,10 +452,14 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_post!(String.t()) :: Post.t()
|
||||
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)
|
||||
|
||||
@spec publish_post_translation(String.t(), String.t() | atom()) ::
|
||||
{:ok, Translation.t()} | {:error, :not_found | term()}
|
||||
def publish_post_translation(post_id, language) do
|
||||
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
||||
|
||||
@@ -424,6 +478,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec slug_available(String.t(), String.t(), String.t() | nil) :: boolean()
|
||||
def slug_available(project_id, slug, exclude_post_id \\ nil) do
|
||||
normalized_slug = slug |> to_string() |> String.trim()
|
||||
|
||||
@@ -441,6 +496,7 @@ defmodule BDS.Posts do
|
||||
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
|
||||
base_slug = title |> default_slug_source() |> Slug.slugify()
|
||||
|
||||
@@ -455,6 +511,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec dashboard_stats(String.t()) :: dashboard_stats()
|
||||
def dashboard_stats(project_id) do
|
||||
Repo.all(
|
||||
from(post in Post,
|
||||
@@ -477,6 +534,7 @@ defmodule BDS.Posts do
|
||||
)
|
||||
end
|
||||
|
||||
@spec post_counts_by_year_month(String.t()) :: [month_count()]
|
||||
def post_counts_by_year_month(project_id) do
|
||||
Repo.all(
|
||||
from(post in Post,
|
||||
@@ -493,6 +551,7 @@ defmodule BDS.Posts do
|
||||
|> Enum.sort_by(fn %{year: year, month: month} -> {-year, -month} end)
|
||||
end
|
||||
|
||||
@spec rebuild_post_links(String.t(), rebuild_opts()) :: :ok
|
||||
def rebuild_post_links(project_id, opts \\ []) do
|
||||
post_ids = Repo.all(from(post in Post, where: post.project_id == ^project_id, select: post.id))
|
||||
on_progress = progress_callback(opts)
|
||||
@@ -517,6 +576,7 @@ defmodule BDS.Posts do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec list_post_translations(String.t()) :: {:ok, [Translation.t()]}
|
||||
def list_post_translations(post_id) do
|
||||
{:ok,
|
||||
Repo.all(
|
||||
@@ -526,6 +586,8 @@ defmodule BDS.Posts do
|
||||
)}
|
||||
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
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -566,6 +628,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_post_translation(String.t()) :: {:ok, :deleted} | {:error, :not_found}
|
||||
def delete_post_translation(translation_id) do
|
||||
case Repo.get(Translation, translation_id) do
|
||||
nil ->
|
||||
@@ -579,6 +642,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_translations(String.t(), rebuild_opts()) :: {:ok, translation_validation_report()}
|
||||
def validate_translations(project_id, opts \\ []) do
|
||||
project = Projects.get_project!(project_id)
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
@@ -657,6 +721,13 @@ defmodule BDS.Posts do
|
||||
}}
|
||||
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
|
||||
normalized_report = normalize_translation_validation_report(report)
|
||||
|
||||
@@ -693,6 +764,7 @@ defmodule BDS.Posts do
|
||||
}}
|
||||
end
|
||||
|
||||
@spec rewrite_published_post(String.t()) :: :ok
|
||||
def rewrite_published_post(post_id) do
|
||||
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}
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
other -> {:error, other}
|
||||
end
|
||||
end,
|
||||
auto_translation_task_attrs(post)
|
||||
@@ -1294,7 +1365,6 @@ defmodule BDS.Posts do
|
||||
%{media_id: media_id, translation_id: saved_translation.id, language: language}
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
other -> {:error, other}
|
||||
end
|
||||
end,
|
||||
auto_translation_task_attrs(post)
|
||||
@@ -1342,10 +1412,12 @@ defmodule BDS.Posts do
|
||||
end
|
||||
|
||||
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
|
||||
{:ok, %{rows: rows}} -> Enum.map(rows, fn [media_id] -> media_id end)
|
||||
{:error, _reason} -> []
|
||||
end
|
||||
Repo.all(
|
||||
from pm in PostMedia,
|
||||
where: pm.post_id == ^post_id,
|
||||
order_by: [asc: pm.sort_order, asc: pm.media_id],
|
||||
select: pm.media_id
|
||||
)
|
||||
end
|
||||
|
||||
defp sync_deleted_post_media_sidecar(media_id) do
|
||||
|
||||
@@ -7,6 +7,16 @@ defmodule BDS.Posts.Link do
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@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
|
||||
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
|
||||
@@ -15,6 +25,7 @@ defmodule BDS.Posts.Link do
|
||||
field :created_at, :integer
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(link, attrs) do
|
||||
link
|
||||
|> cast(attrs, [:id, :source_post_id, :target_post_id, :link_text, :created_at])
|
||||
|
||||
@@ -10,6 +10,35 @@ defmodule BDS.Posts.Post do
|
||||
@foreign_key_type :string
|
||||
@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
|
||||
belongs_to :project, BDS.Projects.Project, type: :string
|
||||
|
||||
@@ -36,6 +65,7 @@ defmodule BDS.Posts.Post do
|
||||
field :published_excerpt, :string
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(post, attrs) do
|
||||
post
|
||||
|> cast(
|
||||
|
||||
40
lib/bds/posts/post_media.ex
Normal file
40
lib/bds/posts/post_media.ex
Normal 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
|
||||
@@ -8,6 +8,25 @@ defmodule BDS.Posts.Translation do
|
||||
@foreign_key_type :string
|
||||
@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
|
||||
belongs_to :post, BDS.Posts.Post,
|
||||
foreign_key: :translation_for,
|
||||
@@ -27,6 +46,7 @@ defmodule BDS.Posts.Translation do
|
||||
field :checksum, :string
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(translation, attrs) do
|
||||
translation
|
||||
|> cast(
|
||||
|
||||
@@ -13,14 +13,35 @@ defmodule BDS.Projects do
|
||||
@default_project_id "default"
|
||||
@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
|
||||
Repo.all(from project in Project, order_by: [asc: project.created_at])
|
||||
end
|
||||
|
||||
@spec get_active_project() :: Project.t() | nil
|
||||
def get_active_project do
|
||||
Repo.one(from project in Project, where: project.is_active == true, limit: 1)
|
||||
end
|
||||
|
||||
@spec shell_snapshot() :: shell_snapshot()
|
||||
def shell_snapshot do
|
||||
_ = ensure_default_project()
|
||||
projects = list_projects()
|
||||
@@ -32,9 +53,13 @@ defmodule BDS.Projects do
|
||||
}
|
||||
end
|
||||
|
||||
@spec get_project(String.t()) :: Project.t() | nil
|
||||
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)
|
||||
|
||||
@spec ensure_default_project() :: {:ok, Project.t()} | {:error, term()}
|
||||
def ensure_default_project do
|
||||
case Repo.get(Project, @default_project_id) do
|
||||
%Project{} = project ->
|
||||
@@ -69,16 +94,19 @@ defmodule BDS.Projects do
|
||||
end
|
||||
end
|
||||
|
||||
@spec project_data_dir(Project.t()) :: String.t()
|
||||
def project_data_dir(%Project{} = project) do
|
||||
project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__)
|
||||
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_id) when is_binary(project_id) do
|
||||
Path.join([project_cache_root(), "projects", project_id])
|
||||
end
|
||||
|
||||
@spec create_project(attrs()) :: {:ok, Project.t()} | {:error, term()}
|
||||
def create_project(attrs) do
|
||||
now = Persistence.now_ms()
|
||||
name = attr(attrs, :name) || ""
|
||||
@@ -108,6 +136,7 @@ defmodule BDS.Projects do
|
||||
end
|
||||
end
|
||||
|
||||
@spec set_active_project(String.t()) :: {:ok, Project.t()} | {:error, :not_found | term()}
|
||||
def set_active_project(project_id) do
|
||||
case Repo.get(Project, project_id) do
|
||||
nil ->
|
||||
@@ -133,6 +162,9 @@ defmodule BDS.Projects do
|
||||
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
|
||||
case Repo.get(Project, project_id) do
|
||||
nil ->
|
||||
|
||||
@@ -7,6 +7,18 @@ defmodule BDS.Projects.Project do
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@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
|
||||
field :name, :string
|
||||
field :slug, :string
|
||||
@@ -19,6 +31,7 @@ defmodule BDS.Projects.Project do
|
||||
has_many :posts, BDS.Posts.Post, foreign_key: :project_id
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(project, attrs) do
|
||||
project
|
||||
|> cast(
|
||||
|
||||
@@ -9,10 +9,15 @@ defmodule BDS.Publishing do
|
||||
alias BDS.Repo
|
||||
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
|
||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||
end
|
||||
|
||||
@spec upload_site(String.t(), credentials(), keyword()) :: {:ok, String.t()} | {:error, term()}
|
||||
def upload_site(project_id, credentials, opts \\ [])
|
||||
when is_binary(project_id) and is_map(credentials) and is_list(opts) do
|
||||
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})
|
||||
end
|
||||
|
||||
@spec get_job(String.t()) :: PublishJob.t() | nil
|
||||
def get_job(job_id) when is_binary(job_id) do
|
||||
GenServer.call(__MODULE__, {:get_job, job_id})
|
||||
end
|
||||
|
||||
@@ -7,6 +7,24 @@ defmodule BDS.Publishing.PublishJob do
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@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
|
||||
field :project_id, :string
|
||||
field :ssh_host, :string
|
||||
@@ -21,6 +39,7 @@ defmodule BDS.Publishing.PublishJob do
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(job, attrs) do
|
||||
job
|
||||
|> cast(
|
||||
|
||||
@@ -62,10 +62,18 @@ defmodule BDS.Search do
|
||||
{"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
|
||||
@stemmer_languages
|
||||
end
|
||||
|
||||
@spec detect_language(String.t() | nil) :: String.t()
|
||||
def detect_language(text) do
|
||||
normalized_text = text |> to_string() |> String.downcase()
|
||||
|
||||
@@ -78,6 +86,7 @@ defmodule BDS.Search do
|
||||
end
|
||||
end
|
||||
|
||||
@spec stem(String.t() | nil, String.t() | nil) :: String.t()
|
||||
def stem(text, language \\ nil) do
|
||||
language = normalize_language(language || detect_language(text))
|
||||
|
||||
@@ -86,6 +95,14 @@ defmodule BDS.Search do
|
||||
|> Enum.map_join(" ", &stem_token(&1, language))
|
||||
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
|
||||
filters = normalize_filters(filters)
|
||||
|
||||
@@ -104,6 +121,14 @@ defmodule BDS.Search do
|
||||
}}
|
||||
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
|
||||
filters = normalize_filters(filters)
|
||||
|
||||
@@ -121,6 +146,7 @@ defmodule BDS.Search do
|
||||
}}
|
||||
end
|
||||
|
||||
@spec reindex_project(String.t()) :: :ok
|
||||
def reindex_project(project_id) do
|
||||
:ok = reindex_posts(project_id)
|
||||
:ok = reindex_media(project_id)
|
||||
@@ -128,6 +154,7 @@ defmodule BDS.Search do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec reindex_posts(String.t(), reindex_opts()) :: :ok
|
||||
def reindex_posts(project_id, opts \\ []) do
|
||||
Repo.query!(
|
||||
"DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)",
|
||||
@@ -150,6 +177,7 @@ defmodule BDS.Search do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec reindex_media(String.t(), reindex_opts()) :: :ok
|
||||
def reindex_media(project_id, opts \\ []) do
|
||||
Repo.query!(
|
||||
"DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)",
|
||||
@@ -172,6 +200,7 @@ defmodule BDS.Search do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec sync_post(Post.t() | String.t()) :: :ok
|
||||
def sync_post(%Post{} = post) do
|
||||
delete_post(post.id)
|
||||
insert_post_index(post)
|
||||
@@ -185,6 +214,7 @@ defmodule BDS.Search do
|
||||
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) when is_binary(post_id) do
|
||||
@@ -192,6 +222,7 @@ defmodule BDS.Search do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec sync_media(Media.t() | String.t()) :: :ok
|
||||
def sync_media(%Media{} = media) do
|
||||
delete_media(media.id)
|
||||
insert_media_index(media)
|
||||
@@ -205,6 +236,7 @@ defmodule BDS.Search do
|
||||
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) when is_binary(media_id) do
|
||||
|
||||
216
lib/bds/wxr_parser.ex
Normal file
216
lib/bds/wxr_parser.ex
Normal 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
|
||||
@@ -64,6 +64,35 @@
|
||||
"translationValidation.revalidate": "Erneut validieren",
|
||||
"translationValidation.fix": "Probleme beheben",
|
||||
"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.setupTitle": "KI-Chat-Einrichtung",
|
||||
"chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich",
|
||||
@@ -97,6 +126,115 @@
|
||||
"sidebar.newPost": "Neuer Beitrag",
|
||||
"sidebar.importMedia": "Medien importieren",
|
||||
"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.templates.newTemplate": "Neue Vorlage",
|
||||
"sidebar.results": "%{count} Ergebnisse",
|
||||
|
||||
@@ -64,6 +64,35 @@
|
||||
"translationValidation.revalidate": "Revalidate",
|
||||
"translationValidation.fix": "Fix Issues",
|
||||
"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.setupTitle": "AI Chat Setup",
|
||||
"chat.apiKeyRequiredTitle": "API Key Required",
|
||||
@@ -97,6 +126,115 @@
|
||||
"sidebar.newPost": "New Post",
|
||||
"sidebar.importMedia": "Import media",
|
||||
"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.templates.newTemplate": "New Template",
|
||||
"sidebar.results": "%{count} results",
|
||||
|
||||
@@ -64,6 +64,35 @@
|
||||
"translationValidation.revalidate": "Revalidar",
|
||||
"translationValidation.fix": "Corregir problemas",
|
||||
"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.setupTitle": "Configuración de chat IA",
|
||||
"chat.apiKeyRequiredTitle": "Clave API requerida",
|
||||
@@ -97,6 +126,115 @@
|
||||
"sidebar.newPost": "Nueva entrada",
|
||||
"sidebar.importMedia": "Importar medios",
|
||||
"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.templates.newTemplate": "Nueva plantilla",
|
||||
"sidebar.results": "%{count} resultados",
|
||||
|
||||
@@ -64,6 +64,35 @@
|
||||
"translationValidation.revalidate": "Revalider",
|
||||
"translationValidation.fix": "Corriger les problèmes",
|
||||
"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 l’entré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 d’archive",
|
||||
"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.welcomeTitle": "Bienvenue dans l’assistant IA",
|
||||
"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.importMedia": "Importer des médias",
|
||||
"sidebar.import.newDefinition": "Nouvelle définition",
|
||||
"importAnalysis.loadingDefinition": "Chargement de la définition d’import…",
|
||||
"importAnalysis.namePlaceholder": "Nom de la définition d’import",
|
||||
"importAnalysis.headerDescription": "Analysez un fichier WXR avant import.",
|
||||
"importAnalysis.uploadsFolder": "Dossier d’uploads",
|
||||
"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 l’analyse.",
|
||||
"importAnalysis.importing": "Import en cours…",
|
||||
"importAnalysis.importComplete": "Import terminé : %{count}",
|
||||
"importAnalysis.importFailed": "Échec de l’import : %{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 d’article",
|
||||
"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": "L’IA 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.templates.newTemplate": "Nouveau modèle",
|
||||
"sidebar.results": "%{count} résultats",
|
||||
|
||||
@@ -64,6 +64,35 @@
|
||||
"translationValidation.revalidate": "Rivalidare",
|
||||
"translationValidation.fix": "Correggi problemi",
|
||||
"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.setupTitle": "Configurazione chat IA",
|
||||
"chat.apiKeyRequiredTitle": "Chiave API richiesta",
|
||||
@@ -97,6 +126,115 @@
|
||||
"sidebar.newPost": "Nuovo post",
|
||||
"sidebar.importMedia": "Importa media",
|
||||
"sidebar.import.newDefinition": "Nuova definizione",
|
||||
"importAnalysis.loadingDefinition": "Caricamento definizione di importazione…",
|
||||
"importAnalysis.namePlaceholder": "Nome definizione di importazione",
|
||||
"importAnalysis.headerDescription": "Analizza un file WXR prima dell’importazione.",
|
||||
"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 l’analisi.",
|
||||
"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": "L’IA 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.templates.newTemplate": "Nuovo modello",
|
||||
"sidebar.results": "%{count} risultati",
|
||||
|
||||
978
priv/ui/app.css
978
priv/ui/app.css
@@ -2550,6 +2550,295 @@ button svg * {
|
||||
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"] {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -6869,6 +7158,695 @@ button svg * {
|
||||
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) {
|
||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
135
priv/ui/live.js
135
priv/ui/live.js
@@ -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: {
|
||||
mounted() {
|
||||
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
||||
|
||||
@@ -119,6 +119,16 @@ defmodule BDS.AITest do
|
||||
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 ->
|
||||
if Enum.any?(request.messages, &(&1["role"] == "tool")) do
|
||||
{:ok,
|
||||
@@ -309,6 +319,36 @@ defmodule BDS.AITest do
|
||||
assert request.model == "gpt-4.1-mini"
|
||||
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
|
||||
assert {:ok, _endpoint} =
|
||||
BDS.AI.put_endpoint(:airplane, %{
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule BDS.CliSyncTest do
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.CliSync
|
||||
alias BDS.CliSync.Watcher
|
||||
alias BDS.Repo
|
||||
|
||||
setup do
|
||||
@@ -24,6 +25,25 @@ defmodule BDS.CliSyncTest do
|
||||
assert is_integer(seen_notification.seen_at)
|
||||
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
|
||||
now = BDS.Persistence.now_ms()
|
||||
|
||||
|
||||
206
test/bds/desktop/import_shell_live_test.exs
Normal file
206
test/bds/desktop/import_shell_live_test.exs
Normal 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
|
||||
@@ -6,6 +6,8 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
|
||||
alias BDS.Persistence
|
||||
alias BDS.AI
|
||||
alias BDS.CliSync.Watcher
|
||||
alias BDS.Menu
|
||||
alias BDS.Media
|
||||
alias BDS.Metadata
|
||||
alias BDS.Posts
|
||||
@@ -202,6 +204,71 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
assert html =~ ~s(data-tab-id="#{created_definition.id}")
|
||||
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
|
||||
{: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")
|
||||
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
|
||||
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||
|
||||
@@ -2054,6 +2229,66 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
refute render(view) =~ "Delayed response"
|
||||
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
|
||||
assert {:ok, _metadata} =
|
||||
BDS.Metadata.update_project_metadata(project.id, %{
|
||||
|
||||
332
test/bds/import_analysis_test.exs
Normal file
332
test/bds/import_analysis_test.exs
Normal 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
|
||||
57
test/bds/import_definitions_test.exs
Normal file
57
test/bds/import_definitions_test.exs
Normal 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
|
||||
233
test/bds/import_execution_test.exs
Normal file
233
test/bds/import_execution_test.exs
Normal 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
|
||||
111
test/bds/wxr_parser_test.exs
Normal file
111
test/bds/wxr_parser_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user