Compare commits
47 Commits
71fb99af16
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d03d033548 | |||
| 74ceaeb971 | |||
| 61ff2a77c0 | |||
| 744f7543d7 | |||
| a1004d72bf | |||
| 489d787306 | |||
| babae1838d | |||
| 5b619f492a | |||
| b3434b3054 | |||
| 5b21dcb17d | |||
| 1f645f6e5e | |||
| 99d36e6e2f | |||
| d7e30b94cb | |||
| f1265ee326 | |||
| c5e09e7316 | |||
| 1ae6152da7 | |||
| 0305d80051 | |||
| a021fc45cd | |||
| fceb995c7c | |||
| e58d68e73e | |||
| 0f30221907 | |||
| d423b6db98 | |||
| 3adb4407a0 | |||
| 05923f255b | |||
| ff89d78ab4 | |||
| e2c92cb90d | |||
| 82ce445c44 | |||
| f99e139fa5 | |||
| 1914b05f39 | |||
| b09b14cc03 | |||
| 721b1ae626 | |||
| f7a4a9512c | |||
| 141c2bfc89 | |||
| a5ac74db91 | |||
| beca4d992f | |||
| 9e6d93a4b3 | |||
| e29dfb490a | |||
| f2b340ba86 | |||
| d18e0ef7f2 | |||
| 2d796cee83 | |||
| b052d59376 | |||
| 4a089b0856 | |||
| 2632649cdc | |||
| 782511d523 | |||
| 1cb59d7a78 | |||
| 9844f3555a | |||
| 99dc1c2216 |
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "phoenix",
|
||||||
|
"runtimeExecutable": "mix",
|
||||||
|
"runtimeArgs": ["phx.server"],
|
||||||
|
"port": 4000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,7 +7,16 @@
|
|||||||
"Bash(mix ecto.migrate)",
|
"Bash(mix ecto.migrate)",
|
||||||
"Bash(git add *)",
|
"Bash(git add *)",
|
||||||
"Bash(git push *)",
|
"Bash(git push *)",
|
||||||
"Bash(git -C /Users/gb/Projects/bDS2 status)"
|
"Bash(git -C /Users/gb/Projects/bDS2 status)",
|
||||||
|
"Bash(git status *)",
|
||||||
|
"Bash(mix assets.deploy)",
|
||||||
|
"Bash(mix phx.server)",
|
||||||
|
"mcp__Claude_Preview__preview_start",
|
||||||
|
"mcp__Claude_in_Chrome__navigate",
|
||||||
|
"mcp__Claude_in_Chrome__computer",
|
||||||
|
"mcp__Claude_in_Chrome__browser_batch",
|
||||||
|
"mcp__Claude_in_Chrome__javascript_tool",
|
||||||
|
"Bash(allium check *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,10 +3,11 @@
|
|||||||
/deps/
|
/deps/
|
||||||
/dist/
|
/dist/
|
||||||
/doc/
|
/doc/
|
||||||
|
/tmp/
|
||||||
/.elixir_ls/
|
/.elixir_ls/
|
||||||
/erl_crash.dump
|
/erl_crash.dump
|
||||||
/node_modules/
|
/node_modules/
|
||||||
/priv/data/*.db
|
/priv/data/*.db
|
||||||
/priv/data/*.db-shm
|
/priv/data/*.db-shm
|
||||||
/priv/data/*.db-wal
|
/priv/data/*.db-wal
|
||||||
*.ez
|
*.eztmp/
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
|
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
|
||||||
- English fallback is allowed only when the requested locale is unsupported by available locale files
|
- English fallback is allowed only when the requested locale is unsupported by available locale files
|
||||||
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
|
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
|
||||||
|
- When adding new `msgid` entries, you MUST provide translations for ALL supported locales (de, fr, it, es) — empty `msgstr` values are not acceptable
|
||||||
|
|
||||||
> **No hardcoded user-facing text. No exceptions.**
|
> **No hardcoded user-facing text. No exceptions.**
|
||||||
|
|
||||||
|
|||||||
509
CODESMELL.md
509
CODESMELL.md
@@ -1,509 +0,0 @@
|
|||||||
# bDS2 Elixir Anti-Pattern & Best-Practice Audit
|
|
||||||
|
|
||||||
> Audited: 2026-05-06
|
|
||||||
> Scope: Elixir application, Phoenix LiveView UI, Ecto DB layer, Desktop (wx) integration, Rendering/Generation pipelines
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to use this file
|
|
||||||
|
|
||||||
1. Pick a section.
|
|
||||||
2. Search the codebase for the file/line references.
|
|
||||||
3. Write a failing test that reproduces the issue.
|
|
||||||
4. Fix the code.
|
|
||||||
5. Run the full test suite and `mix dialyzer`.
|
|
||||||
6. Delete the item from this file.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critical (Fix Immediately)
|
|
||||||
|
|
||||||
### ~~CSM-001 — Atom Table Exhaustion Vulnerability~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-06
|
|
||||||
- **What was done:**
|
|
||||||
- Added `BDS.MapUtils.safe_atomize_key/1` and `BDS.MapUtils.safe_atomize_keys/1` — uses `String.to_existing_atom/1` with rescue fallback to keep unknown keys as strings.
|
|
||||||
- Replaced all 6 affected `String.to_atom` call sites:
|
|
||||||
- `lib/bds/import_definitions.ex` — `atomize_keys/1` → `MapUtils.safe_atomize_keys/1`
|
|
||||||
- `lib/bds/import_execution.ex` — `normalize_report/1` → `MapUtils.safe_atomize_keys/1`
|
|
||||||
- `lib/bds/ai/catalog.ex` — `atomize_map_keys/1` → `MapUtils.safe_atomize_keys/1`, `parse_modality/1` → `MapUtils.safe_atomize_key/1`
|
|
||||||
- `lib/bds/ai/chat_tools.ex` — `metadata_attrs/2` → `MapUtils.safe_atomize_key/1`
|
|
||||||
- `lib/bds/desktop/automation.ex` — `atomize_map/1` → `MapUtils.safe_atomize_keys/1`
|
|
||||||
- Replaced lower-risk `String.to_atom` with `String.to_existing_atom/1`:
|
|
||||||
- `lib/bds/ui/menu_bar.ex` — sidebar view and singleton editor command IDs
|
|
||||||
- `lib/bds/ui/workbench.ex` — `normalize_type/1`
|
|
||||||
- `lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex` — `map_value/3`
|
|
||||||
- `lib/bds/release_packaging.ex` — `normalize_platform/1`
|
|
||||||
- Updated `test/bds/bounded_atoms_test.exs` to enforce no `String.to_atom` on dynamic data (replaced old `String.to_existing_atom` ban).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-002 — Search Loads Entire Tables into Memory~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-07
|
|
||||||
- **What was done:**
|
|
||||||
- Replaced `search_posts/3` and `search_media/3` with SQL-level filtering and pagination.
|
|
||||||
- Blank queries now use pure Ecto queries with `where` clauses for status, language, year/month, date range, tags, categories, and missing translations.
|
|
||||||
- Non-blank (FTS) queries use a CTE (`WITH fts_results AS (...)`) to preserve `bm25` ordering, joined with the posts/media table, with all filters applied in SQL.
|
|
||||||
- Tag and category overlap filtering uses `json_each` in `EXISTS` subqueries.
|
|
||||||
- Missing-translation filtering uses a `NOT EXISTS` correlated subquery.
|
|
||||||
- Count uses `select count` + `Repo.one` instead of `length(all_records)`.
|
|
||||||
- Pagination uses SQL `LIMIT`/`OFFSET` instead of `Enum.drop`/`Enum.take`.
|
|
||||||
- Removed all old Elixir-side filter helpers: `candidate_post_ids`, `load_posts_in_order`, `filter_posts`, `paginate`, `matches_status?`, `matches_overlap?`, etc.
|
|
||||||
- Added comprehensive tests for blank-query and non-blank-query filtering across all filter dimensions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-003 — Non-Atomic Side Effects in Post CRUD~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-07
|
|
||||||
- **What was done:**
|
|
||||||
- Replaced all 11 `Repo.delete!` call sites with `Repo.delete` + `{:error, _}` handling:
|
|
||||||
- `lib/bds/posts.ex` — `delete_post/1`
|
|
||||||
- `lib/bds/scripts.ex` — `delete_script/1`
|
|
||||||
- `lib/bds/media.ex` — `delete_media/1`, `delete_media_translation/3`
|
|
||||||
- `lib/bds/templates.ex` — `delete_template/2`, `remove_orphan_templates/2`
|
|
||||||
- `lib/bds/tags.ex` — `delete_tag/1`, `merge_tags/2`
|
|
||||||
- `lib/bds/projects.ex` — `delete_project/1`
|
|
||||||
- `lib/bds/posts/translations.ex` — `delete_post_translation/1`
|
|
||||||
- `lib/bds/posts/translation_validation.ex` — `fix_invalid_database_row/1`
|
|
||||||
- Reordered `delete_post/1` to perform `Repo.delete` first, then clean up files/embeddings/search/links. Side effects now only run after DB commit succeeds.
|
|
||||||
- Same reordering applied to `delete_script/1`, `delete_media/1`, `delete_template/2`, and `delete_post_translation/1`.
|
|
||||||
- `delete_media/1` now wraps translation + media deletes in a `Repo.transaction` for atomicity.
|
|
||||||
- Tags and projects already used `Repo.transaction`; replaced inner `Repo.delete!` with `Repo.delete` + `Repo.rollback` on error.
|
|
||||||
- Added tests for delete atomicity and not-found handling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-004 — Blocking `init/1` + Missing `terminate/2` in Job Runner~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-08
|
|
||||||
- **What was done:**
|
|
||||||
- Moved `JobStore.attach_runner/2` from `init/1` to a new `handle_continue(:attach_and_start)` callback, so supervisor startup is no longer blocked by the synchronous call.
|
|
||||||
- Added `terminate/2` callback that calls `JobStore.detach_runner/2` (with `try/catch` for shutdown safety), centralizing cleanup that was previously scattered across individual exit paths.
|
|
||||||
- Added `handle_info({:EXIT, _pid, _reason})` clause to handle trapped exit signals from linked processes.
|
|
||||||
- Removed redundant inline `detach_runner` calls from `handle_call(:cancel)`, task result handler, and `:DOWN` handler — `terminate/2` now handles all detach cleanup.
|
|
||||||
- Changed `restart: :temporary` since job runners are one-shot processes that should not auto-restart on failure.
|
|
||||||
- Added `@impl true` to all `handle_info` clauses.
|
|
||||||
- Fixed pre-existing bug in `JobStore.detach_runner` handler where `update_in/2` macro result was incorrectly double-wrapped, corrupting state.
|
|
||||||
- Added test: start a runner, kill it externally (not via cancel), assert `JobStore` no longer contains the dead PID.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-005 — Client-Side Filtering of Entire Tables~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-08
|
|
||||||
- **What was done:**
|
|
||||||
- **Sidebar** (`lib/bds/ui/sidebar.ex`):
|
|
||||||
- Removed `list_posts/1` and `list_media/1` that loaded all records into memory.
|
|
||||||
- Replaced `apply_post_filters/1` and `apply_media_filters/1` (Elixir-side filtering) with SQL `WHERE` clauses using Ecto dynamic queries and SQLite `json_each` fragments.
|
|
||||||
- Page/non-page split now uses `EXISTS (SELECT 1 FROM json_each(categories) WHERE lower(value) = 'page')` in SQL.
|
|
||||||
- Search, year/month, tag, and category filters all push to SQL via `maybe_where_search`, `maybe_where_year`, `maybe_where_month`, `maybe_where_all_tags`, `maybe_where_all_categories`.
|
|
||||||
- Aggregate queries (`year_month_counts`, `available_tags`, `available_categories`) use `Ecto.Adapters.SQL.query!` with `json_each` cross-joins, `GROUP BY`, and `DISTINCT`.
|
|
||||||
- Pagination uses SQL `LIMIT` instead of `Enum.take`.
|
|
||||||
- `tag_count/1` replaces `list_tags/1` + `length/1` with `Repo.one(select: count(tag.id))`.
|
|
||||||
- Fixed `group_posts/1` O(n²) `acc.draft ++ [post]` pattern — now uses `Enum.group_by/2` (also fixes CSM-024).
|
|
||||||
- **Tags** (`lib/bds/tags.ex`):
|
|
||||||
- `posts_with_tag/2` now uses `EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)` instead of loading all posts.
|
|
||||||
- `posts_with_any_tag/2` now uses `json_each` cross-join with a JSON parameter for the tag name list.
|
|
||||||
- `post_tag_names/1` now selects only the `tags` column instead of loading full post records.
|
|
||||||
- **Dashboard** (`lib/bds/ui/dashboard.ex`):
|
|
||||||
- `post_stats` uses `GROUP BY post.status, SELECT {status, count(id)}` — no longer loads all posts.
|
|
||||||
- `media_stats` uses `SELECT count(id), coalesce(sum(size), 0)` and a separate image count query with `LIKE 'image/%'`.
|
|
||||||
- `tag_cloud_items` and `category_counts` use raw SQL with `json_each` cross-joins and `GROUP BY`.
|
|
||||||
- `timeline_entries` uses SQL `strftime` + `GROUP BY` for year/month aggregation.
|
|
||||||
- `recent_posts` uses SQL `ORDER BY updated_at DESC LIMIT 5`.
|
|
||||||
- **Posts** (`lib/bds/posts.ex`):
|
|
||||||
- `dashboard_stats/1` uses `GROUP BY post.status, SELECT {status, count(id)}` instead of loading all statuses.
|
|
||||||
- **Capabilities** (`lib/bds/scripting/capabilities/`):
|
|
||||||
- `tag_post_ids/2` uses `json_each` fragment + `SELECT post.id` instead of loading all posts.
|
|
||||||
- `names_with_counts/2` uses raw SQL with `json_each` + `GROUP BY` instead of loading all posts.
|
|
||||||
- `posts_by_status/2` filters at SQL level instead of loading all posts and filtering in Elixir.
|
|
||||||
- Added 20 tests in `test/bds/csm005_sql_filtering_test.exs` covering dashboard stats, tag cloud, sidebar page/post separation, tag/search/year-month filters, available aggregates, and media filtering.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## High Severity
|
|
||||||
|
|
||||||
### ~~CSM-006 — N+1 Queries in Reindexing & Rendering~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-08
|
|
||||||
- **What was done:**
|
|
||||||
- **Batch INSERT for reindexing:** Replaced per-row `Repo.query!` INSERT in `reindex_posts/2` and `reindex_media/2` with multi-row batch INSERTs. Rows are chunked at 166 per batch (SQLite 999-parameter limit ÷ 6 columns). Translations were already preloaded in batch; fixed O(n²) `acc ++ [translation]` pattern in `preload_post_translations` and `preload_media_translations` by replacing with `Enum.group_by`.
|
|
||||||
- **Rendering — preloaded post records:** `PostRendering.post_assigns/2` now accepts an optional `:_post_record` key in assigns, skipping the `Repo.get(Post, id)` re-query when the record is already available.
|
|
||||||
- **Generation outputs pass records:** `build_page_outputs` and `build_post_outputs` in `outputs.ex` now pass the already-loaded post/translation records via `:_post_record`, eliminating per-post DB queries during generation.
|
|
||||||
- **ListArchive** already used `load_post_records_batch` (batch query) — no change needed.
|
|
||||||
- Added telemetry-based query counting tests: reindex 100 posts/media and assert total query count <10.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-007 — Monolithic State Rebuild ("God Function")~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-09
|
|
||||||
- **What was done:**
|
|
||||||
- Decomposed `reload_shell/2` into four focused updaters:
|
|
||||||
- `refresh_layout/2` — No DB queries. Recomputes workbench-derived assigns (activity_buttons, panel_tabs, current_tab, status_bar, sidebar_header, editor_meta) from existing socket.assigns.
|
|
||||||
- `refresh_sidebar/2` — Queries sidebar data only, then calls `refresh_layout`.
|
|
||||||
- `refresh_content/2` — Queries projects, dashboard, git badge, and sidebar data, then calls `refresh_layout`.
|
|
||||||
- `reload_shell/2` — Full refresh: tab_meta sync, task status, static data, then calls `refresh_content`. Kept for mount, project switch, session restore, and settings changes.
|
|
||||||
- Replaced all call sites with the minimal refresh needed:
|
|
||||||
- **Layout-only** (`refresh_layout`): toggle_sidebar, toggle_panel, toggle_assistant_sidebar, select_panel_tab, sync_layout, resize_panel, open_tasks_panel, select_tab, close_tab, toggle_offline_mode, layout menu actions (toggle, close_tab).
|
|
||||||
- **Sidebar** (`refresh_sidebar`): select_view, all sidebar filter events, sidebar menu actions (view_posts, view_media, edit_preferences, etc.), chat/import editor tab_meta updates.
|
|
||||||
- **Content** (`refresh_content`): entity_changed (CLI sync), tags_changed, sidebar create/delete.
|
|
||||||
- **Full reload** (`reload_shell`): mount, activate_project, restore_workbench_session, set_page_language, settings_changed.
|
|
||||||
- Updated Bridges callbacks to use focused refreshers: `refresh_layout` for toggle events and close_tab, `refresh_sidebar` for view switches and tab meta updates, `refresh_content` for entity/tag changes.
|
|
||||||
- Split `@local_menu_actions` into `@layout_menu_actions` and `@sidebar_menu_actions` for correct dispatch.
|
|
||||||
- Fixed `false || true` bug in `refresh_layout` where `offline_mode = assigns[:offline_mode] || true` incorrectly defaulted `false` to `true`.
|
|
||||||
- Added 7 tests in `test/bds/csm007_reload_shell_test.exs` using telemetry-based query counting: toggle_sidebar (0 queries), toggle_panel (0 queries), sync_layout (0 queries), select_panel_tab (0 queries), toggle_offline_mode (1 query — settings write only), select_view (sidebar queries but no dashboard/projects), sidebar_search (no dashboard queries).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-008 — DB Queries During Render Path~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-09
|
|
||||||
- **What was done:**
|
|
||||||
- **Panel renderer** (`lib/bds/desktop/shell_live/panel_renderer.ex`):
|
|
||||||
- `render_post_links` and `render_git_log` no longer call DB functions during render. Instead they read from pre-computed assigns (`panel_post_links`, `panel_git_entries`).
|
|
||||||
- Renamed `post_link_entries/1` → `fetch_post_link_entries/1` and `git_log_entries/1` → `fetch_git_log_entries/1`, made them public for use by event handlers.
|
|
||||||
- **Shell LiveView** (`lib/bds/desktop/shell_live.ex`):
|
|
||||||
- Added `refresh_panel_data/1` that fetches panel data (post links or git log) based on the active panel tab and stores results in assigns.
|
|
||||||
- `refresh_layout/2` detects when `current_tab` or `panel.active_tab` changed and calls `refresh_panel_data/1` only when stale — no DB queries on re-renders.
|
|
||||||
- Initialized `panel_post_links` and `panel_git_entries` assigns in mount.
|
|
||||||
- **Tab meta** (`lib/bds/desktop/shell_live/tab_helpers.ex`):
|
|
||||||
- `sync_tab_meta` now skips `derived_tab_meta` DB queries when existing meta already has both title and subtitle populated (`meta_complete?/1` guard).
|
|
||||||
- Added 5 tests in `test/bds/csm008_render_path_test.exs`: post_links re-render (0 queries), git_log re-render (0 queries), output panel switch (0 queries), tasks panel switch (0 queries), tab meta skip for complete meta (0 queries).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-009 — Thumbnail Generation: Missing Error Handling~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-09
|
|
||||||
- **What was done:**
|
|
||||||
- Replaced all bang variants with non-bang error-tuple handling:
|
|
||||||
- `Image.autorotate!` → `Image.autorotate` with `{:ok, {image, rotation_info}}` destructuring.
|
|
||||||
- `Image.thumbnail!` → `Image.thumbnail` returning `{:ok, image}` / `{:error, reason}`.
|
|
||||||
- `Image.embed!` → `Image.embed` with `with` chain.
|
|
||||||
- `Image.flatten!` → `Image.flatten` with `with` chain.
|
|
||||||
- `Image.write!` → `Image.write` with `{:ok, _}` / `{:error, reason}` handling.
|
|
||||||
- `File.mkdir_p` result is now checked — errors halt thumbnail generation with `{:error, reason}`.
|
|
||||||
- `write_all_thumbnails` uses `Enum.reduce_while` to stop on first error and return `{:error, reason}`.
|
|
||||||
- `ensure_thumbnails` spec updated to `:ok | {:error, term()}`.
|
|
||||||
- `regenerate_thumbnails` propagates `{:error, reason}` from `ensure_thumbnails`.
|
|
||||||
- `regenerate_missing_thumbnails` replaced `try/rescue` with `case` on the new error tuples.
|
|
||||||
- Call sites in `BDS.Media` (`import_media`, `replace_media_binary`) use `log_thumbnail_error/2` — media operations succeed even if thumbnails fail, with a warning logged.
|
|
||||||
- Added 6 tests in `test/bds/csm009_thumbnail_error_handling_test.exs`: corrupt image returns `{:error, _}`, non-image returns `:ok`, missing source returns `{:error, _}`, regenerate corrupt returns `{:error, _}`, regenerate_missing counts failures, import succeeds despite thumbnail failure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-010 — `rescue` for Control Flow in Data Layer~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-09
|
|
||||||
- **What was done:**
|
|
||||||
- Added `BDS.Repo.ready?/0` — a lightweight probe that queries `sqlite_master` (parameterized) to check if core tables exist, without raising exceptions.
|
|
||||||
- Replaced all 4 `rescue` blocks in `ShellData` (`project_snapshot/0`, `dashboard/1`, `sidebar_view/3`, `git_badge_count/2`) with upfront `Repo.ready?()` checks.
|
|
||||||
- All four functions now return `{:ok, result}` / `{:error, :not_ready}` tuples instead of silently returning defaults via rescue.
|
|
||||||
- Updated callers in `ShellLive.refresh_content/2` and `ShellLive.refresh_sidebar/2` to pattern-match the new tuples and fall back to empty defaults only on `{:error, :not_ready}`.
|
|
||||||
- Made `default_project_snapshot/0` public for use by callers handling the not-ready case.
|
|
||||||
- Added 10 tests in `test/bds/csm010_rescue_control_flow_test.exs`: `Repo.ready?` returns true when DB is available, each of the 4 functions returns `{:ok, _}` when DB is ready and `{:error, :not_ready}` when the Repo is stopped.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Medium Severity
|
|
||||||
|
|
||||||
### ~~CSM-011 — No URL State / Deep Linking~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-09
|
|
||||||
- **What was done:**
|
|
||||||
- `mount/3` now reads `?view=` and `?tab=<type>:<id>` query params and applies them to the initial workbench state, enabling deep linking on page load.
|
|
||||||
- Added `push_url_state/1` — after state-changing events (`select_view`, `select_tab`, `close_tab`, `open_sidebar_item`, sidebar menu actions, project switch), pushes a `url-state` event to the client with the serialized URL.
|
|
||||||
- Added JS handler in the `AppShell` hook that calls `history.replaceState` to update the browser URL without triggering navigation.
|
|
||||||
- URL encoding: `?view=<sidebar_view>` (omitted when `posts`, the default) and `?tab=<type>:<id>` (omitted when no tab is active). Invalid or unknown params are silently ignored.
|
|
||||||
- Used `push_event` + `history.replaceState` instead of `push_patch`/`handle_params` to maintain compatibility with existing `live_isolated` tests.
|
|
||||||
- Added 10 tests in `test/bds/csm011_url_state_test.exs`: mount with `?view=media`, mount with default, mount with invalid view, mount with `?tab=post:<id>`, mount with both params, `select_view` pushes url-state, `select_view` posts pushes clean URL, `select_tab` pushes url-state, `close_tab` removes tab from URL, `open_sidebar_item` pushes url-state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-012 — Desktop File Dialog Blocks Event Handler~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-09
|
|
||||||
- **What was done:**
|
|
||||||
- Replaced synchronous `FilePicker.choose_file/1` call in `SidebarCreate.create/4` for the "media" kind with `Task.async`, storing the task ref in a new `file_picker_task` socket assign.
|
|
||||||
- Added `handle_file_picker_result/2` private function in `ShellLive` with clauses for `{:ok, _media}`, `:cancel`, `{:error, %{message: _}}`, and `{:error, reason}`.
|
|
||||||
- Extended the existing `handle_info({ref, result}, socket)` and `handle_info({:DOWN, ref, ...}, socket)` handlers to match on `file_picker_task` ref.
|
|
||||||
- Added `BDS_DESKTOP_AUTOMATION` guard to `FilePicker.choose_file/1` — returns `:cancel` immediately in automation/test mode, preventing native dialogs from opening during tests.
|
|
||||||
- Initialized `file_picker_task: nil` assign in mount.
|
|
||||||
- Added 5 tests in `test/bds/csm012_file_picker_async_test.exs`: event handler returns within 100ms, LiveView handles other events while task is pending, task completion doesn't crash LiveView, cancel is handled gracefully, error results don't crash LiveView.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-013 — Bang Functions in Rendering Pipelines~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-09
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/rendering/filters.ex`** — `render_macro_template`:
|
|
||||||
- Replaced `Liquex.parse!` with `Liquex.parse` (non-bang) and `case` match on `{:ok, ast}` / `{:error, reason, line}`.
|
|
||||||
- Wrapped `Liquex.render!` in `try/rescue` catching `Liquex.Error` specifically (no non-bang `render` exists in Liquex).
|
|
||||||
- Removed broad `rescue _error -> ""` — errors now log via `Logger.warning` with template path and reason before returning `""`.
|
|
||||||
- **`lib/bds/rendering/template_selection.ex`** — `render_template`:
|
|
||||||
- `Liquex.parse` was already non-bang; added `else` clause to normalize the 3-tuple `{:error, reason, line}` into `{:error, "reason at line N"}`.
|
|
||||||
- Wrapped `Liquex.render!` in `try/rescue` catching `Liquex.Error` specifically, returning `{:error, message}`.
|
|
||||||
- Removed broad `rescue error -> {:error, error}`.
|
|
||||||
- **`lib/bds/rendering/post_rendering.ex`** — `post_data_json_value`:
|
|
||||||
- Replaced `Jason.encode!` with `Jason.encode` and `case` match — returns `"{}"` on encode failure instead of crashing.
|
|
||||||
- Added 5 tests in `test/bds/csm013_bang_rendering_test.exs`: template syntax error returns `{:error, _}` from `render_template`, broken template in `render_post_page` returns `{:error, _}`, `{% break %}` render error returns `{:error, _}`, normal post context produces valid JSON, non-encodable data returns `"{}"` fallback.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-014 — O(n²) Loops from `length/1` Inside Iteration~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-09
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/generation/outputs.ex`** — `build_category_outputs`:
|
|
||||||
- Bound `total_pages = length(paginated_posts)` and `total_items = length(posts)` before the nested loop. Previously called `length/1` 4 times per page × language iteration.
|
|
||||||
- **`lib/bds/generation/outputs.ex`** — `build_root_outputs`:
|
|
||||||
- Bound `total_items = length(posts)` before the loop, reused by `pagination_for_page`. Previously called `length(posts)` on every page iteration.
|
|
||||||
- **`lib/bds/generation/outputs.ex`** — `build_paginated_archive_outputs`:
|
|
||||||
- Bound `total_items = length(posts)` before the loop. Previously called `length(posts)` inside the nested page × language loop.
|
|
||||||
- **`lib/bds/rendering/list_archive.ex`** — `build_day_blocks`:
|
|
||||||
- Bound `last_index = length(grouped_blocks) - 1` before the `Enum.map`. Previously called `length(grouped_blocks)` on every iteration.
|
|
||||||
- **`lib/bds/publishing.ex`** — `run_upload`:
|
|
||||||
- Bound `target_count = max(length(targets), 1)` before the `Enum.reduce_while`. Negligible impact (3 targets) but fixed for consistency.
|
|
||||||
- `lib/bds/ui/sidebar.ex` `acc.draft ++ [post]` was already fixed by CSM-005 (replaced with `Enum.group_by`).
|
|
||||||
- Added 3 tests in `test/bds/csm014_length_in_loop_test.exs`: multi-page pagination correctness, single-page pagination correctness, 1000-post linear time completion.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-015 — Missing DB Indexes on Foreign Keys~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-09
|
|
||||||
- **What was done:**
|
|
||||||
- Added migration `20260509145208_add_missing_indexes.exs` with indexes for all missing foreign keys and frequently filtered columns:
|
|
||||||
- FK indexes: `media.project_id`, `post_media.post_id`, `post_media.media_id`, `chat_messages.conversation_id`, `embedding_keys.post_id`, `embedding_keys.project_id`, `dismissed_duplicate_pairs.project_id`, `import_definitions.project_id`, `publish_jobs.project_id`.
|
|
||||||
- Filter indexes: `posts.status`, `posts.published_at`, `posts.language`.
|
|
||||||
- Composite index: `db_notifications(entity_type, entity_id)`.
|
|
||||||
- Added 12 tests in `test/bds/csm015_missing_indexes_test.exs` verifying via `EXPLAIN QUERY PLAN` that all indexed columns use index lookups.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-016 — String Concatenation for Paths~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-09
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/rendering/file_system.ex`** — Extracted `ensure_liquid_ext/1` using `Path.extname/1` to check before appending `.liquid`, preventing double-extension bugs (e.g. `"header.liquid.liquid"`).
|
|
||||||
- **`lib/bds/rendering/metadata.ex`** — `menu_item_href` for `:page` kind now applies `URI.encode/1` to the slug (matching the existing `:category_archive` pattern). `href_for_language/1` now uses `String.trim_trailing(prefix, "/")` before appending `/` to prevent double trailing slashes.
|
|
||||||
- **`lib/bds/rendering/metadata.ex`** — Added `menu_items_from_raw/1` public function for testability.
|
|
||||||
- **`lib/bds/rendering/links_and_languages.ex`** — `post_path/2` for `nil` language now uses `Path.join(["/", year, month, day, slug]) <> "/"` instead of building with `index.html` then stripping it. Language-prefix clause uses `String.trim_trailing/2` to prevent double slashes. `canonical_media_path_by_source_path/1` uses `Path.join("/", media.file_path)` instead of `"/" <> file_path`.
|
|
||||||
- **`lib/bds/publishing.ex`** — `ensure_trailing_slash/1` made public for testability (implementation already correct).
|
|
||||||
- Added 17 tests in `test/bds/csm016_path_concatenation_test.exs`: FileSystem extension handling (bare name, double extension, nested paths), `href_for_language` (empty, with/without trailing slash), menu item href encoding (special chars, plain slugs, category slugs), post_path construction (leading/trailing slashes, no double slashes, language prefix), `language_prefix` (same/nil/different language), `ensure_trailing_slash` (without/with trailing slash, empty string).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-017 — `send(self(), ...)` Component Chatter~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-09
|
|
||||||
- **What was done:**
|
|
||||||
- Created `BDS.Desktop.ShellLive.Notify` — a single dispatch module that standardizes all parent communication from LiveComponent editors. Provides typed functions: `output/3`, `output/4`, `tab_meta/4`, `tab_meta_merge/3`, `close_tab/2`, `reload/0`, `dirty/3`, `command/2`, `open_sidebar_item/2`, and `parent/1` (escape hatch for chat-specific messages).
|
|
||||||
- Replaced all 25+ `send(self(), ...)` calls across 11 editor components with `Notify.*` calls:
|
|
||||||
- `post_editor.ex` — 13 calls (dirty, tab_meta, close_tab, output)
|
|
||||||
- `media_editor.ex` — 7 calls (dirty, tab_meta, output)
|
|
||||||
- `chat_editor.ex` — 15 calls (output, tab_meta, open_sidebar_item, plus chat-specific via `Notify.parent`)
|
|
||||||
- `template_editor.ex` — 3 calls (close_tab, output, reload)
|
|
||||||
- `script_editor.ex` — 3 calls (close_tab, output, reload)
|
|
||||||
- `misc_editor.ex` — 4 calls (command, output, tab_meta_merge, open_sidebar_item)
|
|
||||||
- `settings_editor.ex` — 2 calls (output, parent)
|
|
||||||
- `tags_editor.ex` — 2 calls (output, parent)
|
|
||||||
- `menu_editor.ex` — 1 call (output)
|
|
||||||
- `import_editor.ex` — 2 calls (tab_meta, output)
|
|
||||||
- `overlay_manager.ex` — 3 calls (parent for cross-component routing)
|
|
||||||
- Consolidated Bridges from 30+ editor-specific `handle_info` clauses to 4 generic handlers: `{:editor_output, ...}`, `{:editor_tab_meta, ...}`, `{:editor_dirty, ...}`, `{:editor_command, ...}`.
|
|
||||||
- Removed 18 editor-specific message atoms from Bridges (`:post_editor_output`, `:media_editor_output`, `:post_editor_dirty`, `:media_editor_dirty`, `:post_editor_tab_meta`, etc.).
|
|
||||||
- Kept chat-specific messages (`{:chat_editor_task_started, ...}`, `{:chat_editor_toggle_sidebar}`, etc.) and cross-component routing (`{:post_editor_insert_content, ...}`) in Bridges since they originate from AI streaming or overlay actions, not from editor self-notification.
|
|
||||||
- Added 24 tests in `test/bds/csm017_component_chatter_test.exs`: 11 source-level tests asserting no `send(self(), ...)` in any editor file, 1 aggregate test verifying all shell_live `send(self(), ...)` calls are in `notify.ex`, 2 Bridges tests verifying old patterns are gone and new generic handlers exist, 10 Notify API tests verifying each function sends the correct message.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Low Severity / Code Quality
|
|
||||||
|
|
||||||
### ~~CSM-018 — `@moduledoc false` Epidemic~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-10
|
|
||||||
- **What was done:**
|
|
||||||
- Replaced `@moduledoc false` with descriptive `@moduledoc` strings in all 12 listed public modules:
|
|
||||||
- `lib/bds/i18n.ex` — language support, locale resolution, flag emoji mapping
|
|
||||||
- `lib/bds/map_utils.ex` — mixed-key map utilities and safe atom conversion
|
|
||||||
- `lib/bds/bounded_atoms.ex` — allow-list-based dynamic atom conversion
|
|
||||||
- `lib/bds/document_fields.ex` — frontmatter field access with key aliases
|
|
||||||
- `lib/bds/import_definitions.ex` — CRUD for WXR import configurations
|
|
||||||
- `lib/bds/publishing.ex` — GenServer for site upload job coordination
|
|
||||||
- `lib/bds/settings.ex` — global key-value settings persistence
|
|
||||||
- `lib/bds/templates.ex` — Liquid template lifecycle management
|
|
||||||
- `lib/bds/ai.ex` — AI endpoint config, secrets, and inference dispatch
|
|
||||||
- `lib/bds/mcp.ex` — MCP server facade for external AI agents
|
|
||||||
- `lib/bds/scripting/capabilities.ex` — Lua scripting capability map builder
|
|
||||||
- `lib/bds/scripting/api_docs.ex` — machine-readable Lua API documentation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-019 — Missing `@spec` on Public Functions~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-10
|
|
||||||
- **What was done:**
|
|
||||||
- Added `@spec` annotations to every public function across 25 files in rendering, generation, publishing, UI, and scripting modules.
|
|
||||||
- Added `@type t :: %__MODULE__{}` to `workbench.ex` and `file_system.ex` to support struct-based specs.
|
|
||||||
- Rendering: `post_rendering.ex`, `links_and_languages.ex`, `labels.ex`, `metadata.ex`, `file_system.ex`, `filters.ex`, `list_archive.ex`, `template_selection.ex`
|
|
||||||
- Generation: `generated_file_hash.ex`
|
|
||||||
- Publishing: `publishing.ex`
|
|
||||||
- UI: `registry.ex`, `session.ex`, `sidebar.ex`, `menu_bar.ex`, `commands.ex`, `dashboard.ex`, `workbench.ex`
|
|
||||||
- Scripting: `job_store.ex`, `job_runner.ex`, `job_supervisor.ex`, `capabilities.ex`, `capabilities/util.ex`, `api_docs.ex`
|
|
||||||
- Dialyzer passes with 0 errors; all 619 tests pass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-020 — Deeply Nested `case` Instead of `with`~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-10
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/import_definitions.ex`** — `delete_definition/1`: Replaced nested `case` piped into another `case` with a flat `with` chain: `Repo.get` → `Repo.delete` → `{:ok, :deleted}`, with `else` clauses for `nil` and `{:error, _}`.
|
|
||||||
- **`lib/bds/publishing.ex`** — `handle_call({:update_job, ...})`: Replaced `case Repo.get` with `with %PublishJob{} = job <- Repo.get(...)`. Also replaced `Repo.update!()` with `Repo.update()` to avoid crashes on changeset errors.
|
|
||||||
- **`lib/bds/templates.ex`** — `update_template/2`: Replaced outer `case Repo.get` with `with` + extracted `do_update_template/2` private function. Collapsed three levels of nested `case` (Repo.get → transaction_result → sync_side_effects) into a single flat `with` chain.
|
|
||||||
- Added 7 tests in `test/bds/csm020_nested_case_test.exs`: delete_definition success and not-found, update_template success and not-found, source-level assertions that all three files use `with` instead of nested `case`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-021 — `cond` Where Pattern Matching Suffices~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-11
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/ai.ex`** — `get_endpoint/2`: Replaced `cond do is_nil(x) and ...; true -> ... end` with a simple `if/else` since there are only two branches.
|
|
||||||
- **`lib/bds/scripting/api_docs.ex`** — `example_response_value/1`: Extracted `"nil"` literal match into a separate function head. Replaced remaining `cond` with `case` on a tuple of guard results.
|
|
||||||
- **`lib/bds/scripting/api_docs.ex`** — `example_field_value/1`: Replaced `cond` with `case` on a tuple of `String.contains?`/`String.ends_with?` results.
|
|
||||||
- Added 2 source-level tests in `test/bds/csm021_cond_pattern_match_test.exs` asserting no `cond do` blocks remain in either file.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-022 — Silent Error Swallowing~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-11
|
|
||||||
- **What was done:**
|
|
||||||
- `execute_macro/4` now returns `{:error, reason}` instead of `{:ok, ""}` when the underlying script execution fails.
|
|
||||||
- Added `Logger.warning/1` call that logs the project ID and error reason before returning the error tuple.
|
|
||||||
- Updated test in `api_test.exs` to assert `{:error, _reason}` instead of `{:ok, ""}` for failing macros.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-023 — SRP Violations~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-11
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/templates.ex`** — `do_update_template/2`:
|
|
||||||
- Extracted `resolve_next_slug/2` — determines slug from attrs or keeps current.
|
|
||||||
- Extracted `content_changed?/2` — checks if content attr differs from effective content.
|
|
||||||
- Extracted `resolve_next_status/2` — pattern-matched function heads for status transition (published + content change → draft).
|
|
||||||
- Extracted `build_update_attrs/5` — assembles the changeset map from resolved values.
|
|
||||||
- Extracted `commit_update_transaction/4` — runs the Repo transaction with cascade logic.
|
|
||||||
- `do_update_template/2` is now a concise pipeline: resolve → build → commit → sync.
|
|
||||||
- **`lib/bds/scripting/capabilities.ex`** — `for_project/2`:
|
|
||||||
- Extracted 13 domain-specific builder functions: `app_capabilities/2`, `project_capabilities/1`, `meta_capabilities/1`, `post_capabilities/1`, `media_capabilities/1`, `script_capabilities/1`, `template_capabilities/1`, `tag_capabilities/1`, `task_capabilities/0`, `sync_capabilities/2`, `publish_capabilities/2`, `chat_capabilities/1`, `embedding_capabilities/1`.
|
|
||||||
- `for_project/2` is now a 15-line dispatch map.
|
|
||||||
- Added 5 tests in `test/bds/csm023_srp_violations_test.exs`: source-level assertions for helper extraction in templates, delegation in do_update_template, builder function presence in capabilities, concise for_project body (≤20 lines), no inline capability definitions in for_project.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-024 — `Enum.reduce` with `acc.draft ++ [post]` (O(n²))~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-08 (as part of CSM-005)
|
|
||||||
- **What was done:** Replaced `acc.draft ++ [post]` with `Enum.group_by/2` in `group_posts/1`. See CSM-005 entry for details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-025 — Hardcoded Language Prefixes~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-11
|
|
||||||
- **What was done:**
|
|
||||||
- Replaced hardcoded `["de/", "fr/", "it/", "es/"]` in `language_match?/2` with dynamically derived prefixes from `plan.blog_languages` and `plan.language`.
|
|
||||||
- `build_outputs/2` now computes `other_prefixes` by rejecting the main language from `blog_languages` and appending `"/"` to each.
|
|
||||||
- `pages_for_language/3` and `language_match?/3` now accept the computed prefixes as a parameter instead of using a hardcoded list.
|
|
||||||
- Works correctly with arbitrary language codes (e.g. `pt-br`, `zh-cn`, `ja`) that were not in the old hardcoded list.
|
|
||||||
- Added 5 tests in `test/bds/csm025_hardcoded_languages_test.exs`: source-level assertion for no hardcoded prefixes, main language exclusion, non-main language inclusion, arbitrary language codes, single-language blog.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-026 — TOCTOU Race Condition in Template File System~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-11
|
|
||||||
- **What was done:**
|
|
||||||
- Extracted `candidate_paths/2` — validates the template path and returns all candidate file paths without checking existence.
|
|
||||||
- Added `try_read/2` — attempts `File.read` on each candidate path sequentially, returning `{:ok, contents}` on first success or `{:error, :enoent}` when all fail. No separate existence check.
|
|
||||||
- Simplified `full_path/2` to delegate to `candidate_paths/2` (returns first candidate for backward compatibility with tests).
|
|
||||||
- Rewrote `Liquex.FileSystem` protocol impl to use `try_read/2` directly, eliminating the TOCTOU window between `File.regular?` and `File.read`.
|
|
||||||
- Added 10 tests in `test/bds/csm026_toctou_file_system_test.exs`: atomic read, missing template, multi-root fallthrough, first-root-wins priority, file-deleted-between-calls safety, protocol read, protocol raise on missing, and path validation (empty, absolute, traversal).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CSM-027 — `if result == :ok` Instead of Pattern Matching
|
|
||||||
- **File:** `lib/bds/templates.ex:445`
|
|
||||||
- **Fix:** Use `case result do :ok -> ...; _ -> ... end`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CSM-028 — Broad `rescue` Swallowing Template Errors
|
|
||||||
- **File:** `lib/bds/rendering/filters.ex:130-132`
|
|
||||||
- **What:** `rescue _error -> ""` swallows all macro template failures silently.
|
|
||||||
- **Fix:** Rescue only specific exceptions, or return `{:error, exception}` and let the caller decide.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CSM-029 — `length/1` in Guards or Comparisons
|
|
||||||
- **Files:** `lib/bds/generation/outputs.ex`, `lib/bds/ui/sidebar.ex`
|
|
||||||
- **What:** `length(list)` is O(n). Using it inside a loop makes the whole loop O(n²).
|
|
||||||
- **Fix:** Bind the length before the loop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CSM-030 — Unchecked `File.mkdir_p` / `File.mkdir_p!`
|
|
||||||
- **Files:** `lib/bds/media/thumbnails.ex:133`, `lib/bds/media/sidecars.ex:24,56`, `lib/bds/release_packaging.ex:80,85`
|
|
||||||
- **What:** Result of `File.mkdir_p/1` is discarded. `File.mkdir_p!/1` in `release_packaging` can crash on permission errors.
|
|
||||||
- **Fix:** Pattern-match `File.mkdir_p/1` or use `with`; replace bang variants with non-bang and handle errors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CSM-031 — `try/rescue` Instead of `with` and Error Tuples
|
|
||||||
- **Files:** `lib/bds/rendering/filters.ex`, `lib/bds/rendering/template_selection.ex`, `lib/bds/desktop/shell_data.ex`
|
|
||||||
- **Fix:** Replace `try/rescue` around expected failures with non-bang functions and `with` chains.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CSM-032 — `Map.get` with Default Instead of Pattern Matching
|
|
||||||
- **Files:** Widespread
|
|
||||||
- **What:** `Map.get(map, key, default)` when the key is expected to exist.
|
|
||||||
- **Fix:** Use pattern matching (`%{key: value} = map`) or `Map.fetch!/2` if the key is required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CSM-033 — `Enum.each` with Side Effects That Should Be Batch Inserts
|
|
||||||
- **Files:** `lib/bds/search.ex:174-177`, `lib/bds/embeddings.ex`
|
|
||||||
- **What:** `Enum.each` used for inserting records. The side-effect pattern is fine, but `Enum.map` + `Repo.insert_all` would be much faster for bulk inserts.
|
|
||||||
- **Fix:** Use `Repo.insert_all` for batch inserts instead of `Enum.each` + `Repo.insert`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CSM-034 — `File.read!` / `File.write!` Without Error Handling
|
|
||||||
- **Files:** `lib/bds/preview_assets.ex:32`, `lib/bds/release_packaging.ex:105`, `lib/bds/templates.ex:488-489`
|
|
||||||
- **Fix:** Use `File.read/1`, `File.write/2`, and handle `{:error, reason}`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CSM-035 — Process Dictionary (`Process.get/put`) Usage
|
|
||||||
- **File:** `lib/bds/desktop/ui_locale.ex:32,49,65`
|
|
||||||
- **What:** `UILocale.put/1` sets process dictionary (`Process.put(@key, locale)`) for UI locale. Used in `ShellLive.render` (Z. 550) and `MenuBar`.
|
|
||||||
- **Fix:** This is isolated to the LiveView/MenuBar process so it's low-risk, but document the invariant explicitly: the process dict key `:bds_ui_locale` is set before each render call.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CSM-036 — Missing `@impl true` on GenServer Callbacks
|
|
||||||
- **File:** `lib/bds/publishing.ex:46,61,71,75`
|
|
||||||
- **What:** Only `init/1` (Z. 36) and the first `handle_call` (Z. 41) have `@impl true`. The remaining `handle_call` clauses at Z. 46, 61, 71, 75 lack it.
|
|
||||||
- **Fix:** Add `@impl true` before every `handle_call`, `handle_cast`, `handle_info`, and `terminate`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Checklist for Agents Picking Up This File
|
|
||||||
|
|
||||||
- [x] All critical items (CSM-001 to CSM-005) have been addressed or explicitly deferred with justification.
|
|
||||||
- CSM-001: Fixed. All `String.to_atom` on dynamic data replaced with `MapUtils.safe_atomize_key/keys` or `String.to_existing_atom`.
|
|
||||||
- CSM-002: Fixed. Search now pushes all filtering and pagination into SQL via Ecto queries and CTEs.
|
|
||||||
- CSM-004: Fixed. `attach_runner` moved to `handle_continue`, `terminate/2` added for cleanup, `restart: :temporary` set, JobStore `detach_runner` bug fixed.
|
|
||||||
- [x] All high-severity items (CSM-006 to CSM-010) have been addressed.
|
|
||||||
- CSM-006: Fixed. Batch INSERT for reindexing, preloaded post records for rendering.
|
|
||||||
- CSM-007: Fixed. Decomposed into refresh_layout, refresh_sidebar, refresh_content, reload_shell.
|
|
||||||
- CSM-008: Fixed. Panel data pre-computed in event handlers, tab meta skips DB for complete entries.
|
|
||||||
- CSM-009: Fixed. All bang Image/File variants replaced with error-tuple handling, `ensure_thumbnails` returns `{:error, _}` instead of crashing.
|
|
||||||
- CSM-010: Fixed. Replaced rescue blocks with `Repo.ready?/0` probe and `{:ok, _}`/`{:error, :not_ready}` tuples.
|
|
||||||
- [x] CSM-001 fix covers ALL 6 affected files, not just `import_definitions.ex`.
|
|
||||||
- [x] CSM-003 fix covers ALL `Repo.delete!` call sites (posts, tags, scripts, media, projects, templates, translations).
|
|
||||||
- [x] CSM-007 decomposition is the prerequisite for fixing CSM-008 (render-path queries).
|
|
||||||
- [x] Tests were written **before** implementation changes (Red → Green → Refactor).
|
|
||||||
- [x] Full test suite passes: `mix test`.
|
|
||||||
- [x] Dialyzer passes cleanly: `mix dialyzer` (zero warnings).
|
|
||||||
- [x] Build succeeds: `mix compile`.
|
|
||||||
- [x] No external JS/CSS referenced in preview/generated HTML (per AGENTS.md).
|
|
||||||
- [x] All UI strings use gettext / i18n, no hardcoded text.
|
|
||||||
- [x] API docs (`API.md`) updated if any API changes were made.
|
|
||||||
- [x] Metadata diff tool and rebuild-from-database updated if metadata changed.
|
|
||||||
- [x] Specs in `specs/` folder updated and validated if behavior changed.
|
|
||||||
- [x] Unused code (including tests for removed features) has been deleted.
|
|
||||||
- [x] This `CODESMELL.md` updated: fixed items removed, new ones added.
|
|
||||||
191
SPECAUDIT.md
Normal file
191
SPECAUDIT.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Spec Audit Process
|
||||||
|
|
||||||
|
This document describes the repeatable process for auditing the Allium specifications against the bDS2 codebase and test suite. Run it whenever specs or code change materially.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The audit produces three categories of findings:
|
||||||
|
|
||||||
|
1. **Spec-claims-not-in-code** — spec describes behavior the code does not implement
|
||||||
|
2. **Code-not-in-spec** — code implements behavior the spec does not describe
|
||||||
|
3. **Spec-claims-not-in-tests** — spec invariants/rules/behaviors lack test coverage
|
||||||
|
|
||||||
|
## Step 1: Map the Territory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all spec files
|
||||||
|
ls specs/*.allium
|
||||||
|
|
||||||
|
# List all source modules
|
||||||
|
ls lib/bds/ lib/bds/**/
|
||||||
|
|
||||||
|
# List all test files
|
||||||
|
ls test/bds/ test/bds/**/
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the mapping between specs and code/test files. Use `specs/bds.allium` as the index — it lists every `use` directive with its domain label.
|
||||||
|
|
||||||
|
## Step 2: Extract Spec Claims
|
||||||
|
|
||||||
|
For each `.allium` file, extract:
|
||||||
|
|
||||||
|
| Claim Type | Pattern | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| **invariant** | `invariant Name:` or lines describing always-true properties | `UniqueSlugPerProject: slugs unique within project` |
|
||||||
|
| **rule** | `rule Name { requires: ... ensures: ... }` | `CreatePost: creates with slug, status=draft` |
|
||||||
|
| **guarantee** | `guarantee Name:` | `SandboxedExecution: no filesystem/process loading` |
|
||||||
|
| **config** | `config { key = value }` | `macro_timeout = 10.seconds` |
|
||||||
|
| **behavior** | Explicit claims in comments or entity descriptions | `"HomeAlwaysPresent: menu always has Home entry"` |
|
||||||
|
|
||||||
|
Record the spec file name, claim name, claim type, and line number for each.
|
||||||
|
|
||||||
|
## Step 3: Compare Spec Claims Against Code
|
||||||
|
|
||||||
|
For each claim, find the corresponding code and verify:
|
||||||
|
|
||||||
|
### 3a. Entity/field existence
|
||||||
|
- Does the Ecto schema have the fields the spec declares?
|
||||||
|
- Are relationships (has_many, belongs_to) present?
|
||||||
|
- Are enum/status values complete?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check schema fields
|
||||||
|
grep -n "field :" lib/bds/posts/post.ex
|
||||||
|
grep -n "has_many\|belongs_to" lib/bds/posts/post.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3b. Rule implementation
|
||||||
|
- Does the code enforce the `requires` preconditions?
|
||||||
|
- Does the code produce the `ensures` postconditions?
|
||||||
|
- Are side-effects (FTS, embeddings, file writes) triggered?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check function implementation
|
||||||
|
grep -n "def create_post" lib/bds/posts.ex
|
||||||
|
grep -n "def publish_post" lib/bds/posts.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3c. Invariant enforcement
|
||||||
|
- Are constraints enforced at the schema level (unique_index, check_constraint)?
|
||||||
|
- Are constraints enforced in changeset validations?
|
||||||
|
- Are constraints enforced in business logic?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database constraints
|
||||||
|
grep -n "unique_index\|check_constraint" priv/repo/migrations/*.ex
|
||||||
|
grep -n "unique_constraint\|validate_" lib/bds/posts/post.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3d. File format compliance
|
||||||
|
- Does the serialization format match the spec's frontmatter values?
|
||||||
|
- Are conditional fields omitted when falsy?
|
||||||
|
- Are required fields always present?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check serialization
|
||||||
|
grep -n "serialize\|write_file\|Frontmatter" lib/bds/frontmatter.ex lib/bds/posts/file_sync.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Compare Code Against Spec Claims
|
||||||
|
|
||||||
|
Search for code that implements behavior NOT described in any spec:
|
||||||
|
|
||||||
|
### 4a. Public API functions not in any spec rule
|
||||||
|
```bash
|
||||||
|
# List public functions in a module
|
||||||
|
grep -n "def " lib/bds/posts.ex | grep -v "defp"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4b. Schema fields not in any spec entity
|
||||||
|
```bash
|
||||||
|
# List all fields
|
||||||
|
grep -n "field :" lib/bds/posts/post.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4c. Side effects not in engine_side_effects.allium
|
||||||
|
```bash
|
||||||
|
# Check what happens after CRUD operations
|
||||||
|
grep -n "sync_post\|sync_media\|Search\.\|Embeddings\.\|AutoTranslation" lib/bds/posts.ex lib/bds/media.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4d. UI features not in any editor spec
|
||||||
|
```bash
|
||||||
|
# Check HEEx templates for UI elements
|
||||||
|
grep -n "phx-click\|data-phx-" lib/bds/desktop/post_editor_html/post_editor.html.heex
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Compare Spec Claims Against Tests
|
||||||
|
|
||||||
|
For each invariant, rule, and guarantee, search for a test that verifies it:
|
||||||
|
|
||||||
|
### 5a. Direct test search
|
||||||
|
```bash
|
||||||
|
# Search test names and bodies
|
||||||
|
grep -rn "test \"" test/bds/posts_test.exs | head -30
|
||||||
|
grep -rn "test \"" test/bds/media_test.exs | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5b. Invariant coverage check
|
||||||
|
For each invariant, determine:
|
||||||
|
- **YES**: Test explicitly verifies the invariant (creates violation, expects rejection)
|
||||||
|
- **PARTIAL**: Test verifies the happy path but not violation scenarios
|
||||||
|
- **NO**: No test exists
|
||||||
|
|
||||||
|
### 5c. Rule coverage check
|
||||||
|
For each rule, determine:
|
||||||
|
- **YES**: Test exercises `requires` precondition and `ensures` postcondition
|
||||||
|
- **PARTIAL**: Test exercises the happy path but not preconditions or all postconditions
|
||||||
|
- **NO**: No test exists
|
||||||
|
|
||||||
|
### 5d. Side-effect chain coverage
|
||||||
|
For each side-effect rule in `engine_side_effects.allium`, check whether a test verifies ALL `ensures` clauses fire together (not just individually).
|
||||||
|
|
||||||
|
## Step 6: Classify Findings
|
||||||
|
|
||||||
|
Each gap falls into one of these categories with a recommended action:
|
||||||
|
|
||||||
|
| Category | Direction | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Spec correct, code wrong** | Spec → Code | Fix the code |
|
||||||
|
| **Code correct, spec drifted** | Code → Spec | Update the spec |
|
||||||
|
| **Code behavior, no spec** | Code → Spec | Distill into spec |
|
||||||
|
| **Spec claim, no test** | Spec → Test | Write test |
|
||||||
|
| **Internal spec inconsistency** | Spec → Spec | Align specs |
|
||||||
|
| **Decision needed** | Both | Resolve with stakeholder |
|
||||||
|
|
||||||
|
## Step 7: Produce SPECGAPS.md
|
||||||
|
|
||||||
|
Consolidate all findings into `SPECGAPS.md` with:
|
||||||
|
- Gap ID for tracking
|
||||||
|
- Clear description of the gap
|
||||||
|
- Which spec file and line
|
||||||
|
- Which code file and line
|
||||||
|
- Recommended path (fix code / update spec / write test / decide)
|
||||||
|
- Priority (HIGH/MEDIUM/LOW)
|
||||||
|
|
||||||
|
## Step 8: Validate
|
||||||
|
|
||||||
|
After making changes:
|
||||||
|
```bash
|
||||||
|
# Run full test suite
|
||||||
|
mix test
|
||||||
|
|
||||||
|
# Run dialyzer
|
||||||
|
mix dialyzer
|
||||||
|
|
||||||
|
# Validate allium specs (if tool available)
|
||||||
|
# Use the allium CLI to validate spec files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Re-running the Audit
|
||||||
|
|
||||||
|
1. Start from Step 2 — re-extract claims from updated specs
|
||||||
|
2. Run Steps 3-5 against current code and tests
|
||||||
|
3. Compare against previous SPECGAPS.md to identify resolved and new gaps
|
||||||
|
4. Update SPECGAPS.md
|
||||||
|
|
||||||
|
The audit should be re-run after:
|
||||||
|
- Adding new spec files or significant spec changes
|
||||||
|
- Adding new features or refactoring code
|
||||||
|
- Adding new test files
|
||||||
|
- Before any release milestone
|
||||||
197
SPECGAPS.md
Normal file
197
SPECGAPS.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# Spec Gaps — Allium Specs vs Code vs Tests
|
||||||
|
|
||||||
|
Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update spec | **ST** = write test | **SD** = decide | **SI** = fix internal spec inconsistency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Spec Claims Not Fulfilled by Code
|
||||||
|
|
||||||
|
### A1. Code Must Change (spec is normative)
|
||||||
|
|
||||||
|
| ID | Gap | Spec | Code | Path |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A1-1 | ~~No `archived→draft` or `archived→published` transition~~ | post.allium:121-122 | `unarchive_post/1` implemented, `publish_post` already handled archived→published | **Resolved:** `unarchive_post/1` in posts.ex restores content from disk, UI wired via quick actions, 4 tests added |
|
||||||
|
| A1-2 | ~~`DeletePost` must delete translations + translation files~~ | post.allium:209-212 | `delete_post/1` now fetches translations before cascade-delete and removes their files from disk | **Resolved:** translation file cleanup added to `delete_post/1` in posts.ex, test added |
|
||||||
|
| A1-3 | ~~Publish must delete old file when path changes~~ | engine_side_effects.allium:73-74 | `publish_post` now deletes old file when `file_path` changes | **Resolved:** old file deletion added to `publish_post/1` in posts.ex, test added |
|
||||||
|
| A1-4 | ~~`doNotTranslate: false` written to frontmatter despite "only when true"~~ | frontmatter.allium:398 | `file_sync.ex:78` now converts false→nil so serializer omits the key | **Resolved:** doNotTranslate omitted from frontmatter when false, test added |
|
||||||
|
| A1-5 | ~~Auto-save after 3000ms idle~~ | editor_post.allium:183-188 | PostEditor schedules auto-save via parent timer on dirty change | **Resolved:** 3000ms idle auto-save timer in Bridges, tab-switch save in ShellLive, cancel on manual save, 3 tests added |
|
||||||
|
| A1-6 | ~~On-demand rendering in preview server~~ | preview.allium:53-93 | `Preview.Router` matches post/archive/home/language routes and renders on-demand via `Rendering` | **Resolved:** `Preview.Router` implements on-demand template rendering for post, archive, home, date, tag, category, page, and language-prefixed routes; static file fallback retained for non-HTML assets (pagefind, feeds); 6 tests added |
|
||||||
|
| A1-7 | ~~Template lookup must use all 4 levels (post→tag→category→default)~~ | template_context.allium:267-277 | `resolve_post_template_slug/3` implements tag→category cascade; all callers (preview, generation) updated | **Resolved:** `resolve_post_template_slug/3` in template_selection.ex, callers in preview.ex, router.ex, outputs.ex updated, 8 tests added |
|
||||||
|
| A1-8 | ~~`ValidateLiquid`/`ValidateScript` before publish~~ | template.allium:110, script.allium:165 | `publish_template` validates Liquid via `Liquex.parse`, `publish_script` validates Lua via `BDS.Scripting.validate` | **Resolved:** validation gates added to `publish_template/1` and `publish_script/1`, invalid content returns `{:error, {:invalid_liquid|:invalid_script, reason}}`, 4 tests added |
|
||||||
|
| A1-9 | ~~17 preset colors + custom hex in tag picker~~ | editor_tags.allium | `ColourPicker` hook + popover with 17 preset swatches grid and custom hex input, wired to both create and edit forms | **Resolved:** replaced native `<input type="color">` with `ColourPickerPopover` component (17 presets, custom hex #RRGGBB, immediate selection), JS hook for click-away dismiss, 1 test added |
|
||||||
|
| A1-10 | ~~Template file written on create~~ | engine_side_effects.allium:151-153 | `create_template` now computes `file_path` and writes template file with YAML frontmatter on create | **Resolved:** `create_template/1` writes `templates/{slug}.liquid` on create, `next_template_file_path` always computes path, 1 test added |
|
||||||
|
| A1-11 | ~~Graceful shutdown with inflight request tracking~~ | preview.allium:47-48 | `stop_preview` now closes the listener, parks the reply, and drains monitored inflight request tasks before reporting stopped | **Resolved:** acceptor transfers socket ownership to each request task; GenServer monitors inflight tasks, `begin_graceful_stop` stops accepting and finalizes via `:DOWN`/`:drain_timeout` (5s force-kill cap), 1 test added |
|
||||||
|
| A1-12 | ~~Real Pagefind integration for search~~ | generation.allium:208 | Functional client-side search: `PagefindUI` defined in bundled `pagefind-ui.js`, fragment index records url/title/body-scoped text per page, search-runtime wires it up | **Resolved:** bundled real `PagefindUI` (fetch index, ranked full-text match, highlighted excerpts) + `pagefind-ui.css` as local assets read into `Pagefind`; index scoped to `data-pagefind-body` (unmarked pages excluded per PagefindHtmlMarking), title from `<title>`/`<h1>`; localized "No results found" label via `data-search-no-results` (de/fr/it/es); 3 unit tests added |
|
||||||
|
| A1-13 | ~~Git sidebar shows only "Working tree" placeholder~~ | sidebar_views.allium:651-770 | `git_view/1` now builds a full `layout: "git"` view from `BDS.Git` (repository/remote_state/status/history); `SidebarComponents` renders active + not_a_repo states | **Resolved:** `git_view/1` in sidebar.ex assembles branch/upstream/ahead/behind, status files, paginated history (20/page); `render_git_sidebar` renders branch header, sync legend, fetch/pull/push/prune-lfs buttons, commit form, clickable status files (open git_diff), history entries; shell_live wires `git_commit` (closes git_diff tabs), `git_fetch`/`git_pull`/`git_push`/`git_prune_lfs`, `git_initialize`; `BDS.Git.history` enriched with author/date, `BDS.Git.set_remote/2` added; i18n for de/fr/it/es; 3 shell tests + git author/date assertions added |
|
||||||
|
| A1-14 | ~~Embedding uses TF-IDF hash projection instead of real neural model~~ | embedding.allium:44-53, invariants RealNeuralModel/ModelCaching/VectorCacheInDb | `Backends.Neural` runs `intfloat/multilingual-e5-small` (e5 weights behind the Xenova id) via Bumblebee+EXLA | **Resolved (core):** added bumblebee/nx/exla deps; `Backends.Neural` is a lazily-loaded GenServer that builds the Bumblebee text-embedding serving on first request (`"query: "` prefix + mean pooling + L2 norm), downloads+caches the model under the app data dir (ModelCaching), and is wired into the supervision tree when configured; vectors now persisted as packed little-endian Float32 BLOB (384×4=1536 bytes) instead of JSON text (VectorCacheInDb) with migration recreating `embedding_keys.vector` as BLOB; `InApp` demoted to documented offline/test stub; test config uses the stub so the suite stays offline; spec EmbeddingModel clarified (Xenova id ↔ intfloat weights via Bumblebee); batched inference via optional `embed_many/2` backend callback (configurable `batch_size`/`sequence_length`; rebuild/index/repair embed in chunks instead of one post at a time) + `NativeAcceleratedExecution` invariant added to spec; 4 tests added (BLOB round-trip, batched-rebuild, Neural model_info/behaviour). **Deferred:** A1-14b USearch HNSW index, A1-14c Apple GPU (EMLX). |
|
||||||
|
| A1-14b | ~~USearch HNSW ANN index + debounced persistence not implemented~~ | embedding.allium config/FindSimilar/DebouncedPersistence | `Embeddings.Index` is now an HNSW (hnswlib) ANN index with debounced persistence | **Resolved:** rewrote `Embeddings.Index` as a DB-free GenServer wrapping an hnswlib HNSW graph (cosine, M=16, efConstruction=128, efSearch=64) — O(n·log n) build, O(log n) queries, replacing the O(n²) JSON cosine snapshot; per-project in-memory index + `label→post_id` map; 5s debounced `save_index` + `.meta.json` sidecar, force-save on project switch (`set_active_project`) and shutdown (`terminate`), `forget/1` on project delete; lazy reload from disk with rebuild-from-DB self-heal on miss; `find_similar`/`find_duplicates`/`compute_similarities` rewired (no brute-force fallback); USearch has no Elixir binding so hnswlib provides the identical HNSW algorithm/params (spec reconciled); supervision + dialyzer PLT updated; tests updated for debounced/binary persistence + self-heal. Follow-up hardening: explicit rebuild now forces re-embedding regardless of content_hash (ReindexAll), and model-unavailable errors propagate cleanly (post saves degrade to unindexed + log; rebuild/index return `{:error, reason}` surfaced as a failed task with a user-facing message instead of crashing). |
|
||||||
|
| A1-14c | Embedding model runs on CPU only; no Apple GPU acceleration | embedding.allium invariant NativeAcceleratedExecution | `Backends.Neural` uses Bumblebee+EXLA; on Apple Silicon XLA has no Metal backend so inference is native CPU (batched). Apple GPU/Neural Engine unused | Fix code: spike an EMLX (Apple MLX) Nx backend so the model executes on the Apple Silicon GPU; gate by platform/availability with EXLA-CPU fallback; verify Bumblebee serving + defn compiler compatibility and benchmark vs CPU batching |
|
||||||
|
| A1-15 | ~~Preview vs generation content source strategy undocumented~~ | preview.allium (no invariant), generation.allium (no invariant) | Generation uses only published .md file content (`Generation.Data` snapshots set `content: nil`); preview includes published+draft posts and prefers DB content over file (`Preview.Router` queries `:published`/`:draft`, uses `editor_body`) | **Resolved:** added `PreviewDraftOverlay` invariant to preview.allium and `GenerationPublishedOnly` invariant to generation.allium; both cross-reference each other; code already correct, 3 tests added for draft-in-preview behavior |
|
||||||
|
|
||||||
|
### A2. Spec Should Update (code is normative)
|
||||||
|
|
||||||
|
| ID | Gap | Spec | Code | Path |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A2-1 | ~~WYSIWYG/visual editor mode (3 modes)~~ | editor_post.allium:159-164 | Only markdown+preview; visual normalizes to markdown | **Resolved:** spec updated to 2 modes (markdown/preview), visual/WYSIWYG dropped |
|
||||||
|
| A2-2 | ~~Template/Script are global entities~~ | template.allium, script.allium | Both have `project_id`, per-project uniqueness | **Resolved:** spec updated — added `project_id` to entities, scoped uniqueness invariants and create rules per project |
|
||||||
|
| A2-3 | ~~TagsFile uses `{tags: [...]}` wrapper~~ | frontmatter.allium:255-273 | Code writes bare array `[...]` | **Resolved:** spec updated — removed wrapper object, TagEntry is now the top-level value, bare array in invariant, camelCase keys |
|
||||||
|
| A2-4 | ~~Sidecar is "YAML-like, not gray-matter"~~ | frontmatter.allium:174 | Code wraps with `---` delimiters | **Resolved:** spec updated — format comment now says gray-matter style with --- delimiters |
|
||||||
|
| A2-5 | ~~Translation frontmatter omits status/timestamps~~ | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | **Resolved:** spec updated — TranslationFrontmatter now includes status, created_at, updated_at, published_at; TranslationFilesInheritCanonicalMetadata renamed to TranslationFrontmatterRoundtrip; translation.allium invariant updated to TranslationFilesCarryFullMetadata |
|
||||||
|
| A2-6 | ~~Search index has single `stemmed_content`~~ | search.allium:40-54 | FTS5 per-field stemmed columns | **Resolved:** spec updated — PostSearchIndex has title/excerpt/content/tags/categories; MediaSearchIndex has title/alt/caption/original_name/tags; SearchMedia now accepts filters; index rules use delete-and-reinsert with per-field stemming |
|
||||||
|
| A2-7 | ~~Tag archives are single-page~~ | generation.allium:142-147 | Code paginates | **Resolved:** spec updated — GenerateTagPages now paginated like categories, using max_posts_per_page |
|
||||||
|
| A2-8 | ~~Date archives year+month only~~ | generation.allium:151-159 | Code also generates day-level | **Resolved:** spec updated — GenerateDateArchivePages now includes day-level archives, all three levels paginated |
|
||||||
|
| A2-9 | ~~Menu is DB entity~~ | menu.allium:20-26 | Purely file-based OPML, no DB table | **Resolved:** spec updated — `entity Menu` changed to `value Menu`, file-only model with OPML persistence, added LoadMenu/SyncMenuFromFilesystem rules |
|
||||||
|
| A2-10 | ~~Panel tabs: problems, terminal~~ | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | **Resolved:** spec already lists tasks/output/post_links/git_log with availability and fallback rules matching code |
|
||||||
|
| A2-11 | ~~Git sidebar: commit input, history, push/pull~~ | sidebar_views.allium | Only "Working tree" item | **Moved to A1-13:** backend code exists in BDS.Git, sidebar must wire it up |
|
||||||
|
| A2-12 | ~~Slug timestamp fallback after 999~~ | post.allium:21 | Unbounded numeric suffix | **Resolved:** spec updated — uniqueness comment now says unbounded numeric suffix, no 999 cap or timestamp fallback |
|
||||||
|
| A2-13 | ~~Thumbnail generation is async~~ | engine_side_effects.allium:117 | Synchronous | **Resolved:** spec updated — import thumbnail generation now says synchronous (awaited, logged on error), matching code; summary table changed from `async` to `sync` |
|
||||||
|
| A2-14 | ~~AiModelModality: :video vs :file/:tool~~ | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | **Resolved:** spec updated — modality enum now lists "text" \| "image" \| "audio" \| "file" \| "tool", matching code |
|
||||||
|
| A2-15 | ~~JSON key convention: snake_case vs camelCase~~ | frontmatter.allium values | Code uses camelCase for all metadata JSON | **Resolved:** all value types in frontmatter.allium updated to camelCase field names; added CamelCaseKeys invariant; surfaces updated; also added linkedPostIds to MediaSidecar (C-2) and projectId to TemplateFrontmatter/ScriptFrontmatter (B1-9) |
|
||||||
|
| A2-16 | ~~Snowball stemmer language list~~ | search.allium:26-31 | Library determines which have algorithms vs passthrough | **Resolved:** spec updated — StemmerLanguage comment now says "Snowball stemmers via library (Stemex); languages with algorithm get real stemming, others pass through" |
|
||||||
|
| A2-17 | ~~`provider_package_ref` on AiModel~~ | schema.allium:282 | Not in code; legacy field not needed | **Resolved:** dropped from AiModel entity and AiModelRecordSurface in schema.allium; DB column retained (migration artifact) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Code Behavior Not in Spec
|
||||||
|
|
||||||
|
### B1. Must Add to Spec (domain-level, affects behavior)
|
||||||
|
|
||||||
|
| ID | Behavior | Code Location | Path |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1-1 | Chat inline surfaces (9 types: card, chart, form, list, metric, mindmap, table, tabs, text/json) | `lib/bds/ui/chat/tool_surfaces.ex:6-15` | Distill into spec |
|
||||||
|
| B1-2 | Auto-translation system (AutoTranslation.maybe_schedule, media cascade, batch fill) | `lib/bds/posts/auto_translation.ex` | Distill into spec |
|
||||||
|
| B1-3 | 3 extra settings sections (Technology, MCP, Data Maintenance) | `lib/bds/ui/settings_editor/` | Distill into spec |
|
||||||
|
| B1-4 | Style/Theme as separate tab (`:style`), not settings section | `lib/bds/ui/style_editor.ex` | Distill into spec |
|
||||||
|
| B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity |
|
||||||
|
| B1-6 | Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering) | `lib/bds/rendering/` | Distill into spec |
|
||||||
|
| B1-7 | 404.html generation | `lib/bds/generation/outputs.ex:344-345` | Add to generation.allium |
|
||||||
|
| B1-8 | ~~`linkedPostIds` in media sidecar~~ | `lib/bds/media/sidecars.ex:42` | **Resolved:** added to MediaSidecar value in frontmatter.allium (with A2-15) |
|
||||||
|
| B1-9 | ~~`projectId` in template/script frontmatter~~ | `templates.ex:337`, `scripts.ex:268` | **Resolved:** added projectId to TemplateFrontmatter and ScriptFrontmatter in frontmatter.allium (with A2-15) |
|
||||||
|
| B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium |
|
||||||
|
| B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium |
|
||||||
|
| B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium |
|
||||||
|
| B1-13 | `:confirm_dialog` generic confirmation | `shell_overlay.html.heex:171-187` | Add to modals.allium |
|
||||||
|
| B1-14 | Publish actions for scripts and templates | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | Add to editor_script.allium, editor_template.allium |
|
||||||
|
| B1-15 | `:import` as full editor tab | `lib/bds/ui/import_editor.ex` | Add to tabs.allium |
|
||||||
|
| B1-16 | `:documentation`/`:api_documentation` tab types | `lib/bds/desktop/misc_editor/` | Add to tabs.allium |
|
||||||
|
| B1-17 | Metadata diff covers embedding, media_translation, post_translation as entity types | `lib/bds/maintenance/repair.ex` | Add to metadata_diff.allium |
|
||||||
|
| B1-18 | Finished task TTL eviction (1h, keep last 10) | `lib/bds/tasks.ex:365-386` | Add to task.allium |
|
||||||
|
| B1-19 | `discard_post_changes/1` | `lib/bds/posts.ex:201-227` | Add to post.allium |
|
||||||
|
| B1-20 | `replace_media_file/2` with checksum/backup | `lib/bds/media.ex:288-337` | Add to media.allium |
|
||||||
|
|
||||||
|
### B2. Lower Priority (implementation detail or minor)
|
||||||
|
|
||||||
|
| ID | Behavior | Code Location |
|
||||||
|
|---|---|---|
|
||||||
|
| B2-1 | `editor_body/1` content resolver | `lib/bds/posts.ex:229-252` |
|
||||||
|
| B2-2 | `sync_post_from_file/1` single-post reimport | `lib/bds/posts.ex:254-279` |
|
||||||
|
| B2-3 | `import_orphan_post_file/1` | `lib/bds/posts.ex:289-291` |
|
||||||
|
| B2-4 | `dashboard_stats/1`, `post_counts_by_year_month/1` | `lib/bds/posts.ex:378-413` |
|
||||||
|
| B2-5 | `regenerate_missing_thumbnails/2` | `lib/bds/media.ex:47-48` |
|
||||||
|
| B2-6 | Cache dir computation | `lib/bds/projects.ex:101-106` |
|
||||||
|
| B2-7 | `remove_stale_published_templates` | `lib/bds/templates.ex:524-552` |
|
||||||
|
| B2-8 | Rendering Labels module (30+ i18n strings) | `lib/bds/rendering/labels.ex` |
|
||||||
|
| B2-9 | Progress reporting during reindex | `lib/bds/generation/progress.ex` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Internal Spec Inconsistencies
|
||||||
|
|
||||||
|
All reconciled to follow code. Specs must be self-consistent and match code.
|
||||||
|
|
||||||
|
| ID | Conflict | Resolution | Path |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C-1 | schema.allium ChatMessage has no cache tokens; ai.allium ChatMessage has `cache_read_tokens`/`cache_write_tokens` | Code has cache tokens → align schema.allium with ai.allium | Update schema.allium |
|
||||||
|
| C-2 | ~~media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it~~ | Code writes `linkedPostIds` → add to frontmatter.allium | **Resolved:** linkedPostIds added to MediaSidecar in frontmatter.allium (with A2-15) |
|
||||||
|
| C-3 | ~~translation.allium says status/timestamps omitted; frontmatter.allium TranslationFrontmatter defines only 5 fields; code writes 8+ fields~~ | Code writes status/timestamps → update both specs to match code | **Resolved:** both specs updated (see A2-5) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Spec Claims Not Covered by Tests
|
||||||
|
|
||||||
|
### D1. No Test Coverage (HIGH priority — invariants/guarantees)
|
||||||
|
|
||||||
|
| ID | Claim | Spec | Path |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1-1 | UniqueMediaTranslation invariant | media.allium:108 | Write test: create duplicate media translation, expect rejection |
|
||||||
|
| D1-2 | UniqueTranslationPerLanguage invariant | translation.allium:94 | Write test: create duplicate post translation, expect rejection |
|
||||||
|
| D1-3 | BundledDefaultTemplatesExistOutsideProjectData | template.allium:65 | Write test: render with no Template rows, bundled template found |
|
||||||
|
| D1-4 | UserTemplateDirectoryOverridesBundledDefaults | template.allium:75 | Write test: project template overrides bundled same-slug |
|
||||||
|
| D1-5 | LiquidTagSubset (5 tags only) | template.allium:179 | Write test: unsupported tag raises error |
|
||||||
|
| D1-6 | LiquidFilterSubset (4 standard + 2 custom) | template.allium:191 | Write test: unsupported filter raises error |
|
||||||
|
| D1-7 | LiquidOperatorSubset | template.allium:210 | Write test: unsupported operator raises error |
|
||||||
|
| D1-8 | MacroTimeout guarantee | script.allium:94-95 | Write test: macro times out within budget |
|
||||||
|
| D1-9 | ExecuteTransform rule (pipeline, ordering, toast budget) | script.allium:229-263 | Write test: transform pipeline executes in order, toast budget enforced |
|
||||||
|
| D1-10 | TransformPipelineContinuation | script.allium:247-249 | Write test: error in transform doesn't halt pipeline |
|
||||||
|
| D1-11 | ChatContextTruncation invariant | ai.allium:375-379 | Write test: long chat history trimmed to context window |
|
||||||
|
| D1-12 | BoundedToolLoop enforcement | ai.allium:381-385 | Write test: tool rounds bounded by chat_max_tool_rounds |
|
||||||
|
| D1-13 | DiscardPostChangesSideEffects | engine_side_effects.allium:99-104 | Write test: FTS updated after discard |
|
||||||
|
| D1-14 | ReplaceMediaFileSideEffects | engine_side_effects.allium:128-134 | Write test: file replaced, thumbnails regenerated |
|
||||||
|
| D1-15 | Drag-and-drop image chain | action_patterns.allium:84-103 | Write integration test |
|
||||||
|
| D1-16 | DebouncedPersistence (5s) | embedding.allium:204-208 | Write test: index persistence debounced |
|
||||||
|
| D1-17 | Protected categories cannot be deleted | editor_settings.allium:81-84 | Write test: article/aside/page/picture deletion rejected |
|
||||||
|
| D1-18 | HomeItemProtection (menu) | editor_misc.allium:206-209 | Write test: cannot move/reorder/delete Home |
|
||||||
|
|
||||||
|
### D2. No Test Coverage (MEDIUM priority — rules/behaviors)
|
||||||
|
|
||||||
|
| ID | Claim | Spec | Path |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D2-1 | RemoveCategory rule | metadata.allium:100 | Write test: remove category, verify list+settings+JSON updated |
|
||||||
|
| D2-2 | CreateAndPublishTemplate rule | template.allium:105 | Write test: create+publish in one step |
|
||||||
|
| D2-3 | CreateAndPublishScript rule | script.allium:160 | Write test: create+publish in one step |
|
||||||
|
| D2-4 | UniqueScriptSlug dedup | script.allium:115 | Write test: two scripts same title → dedup slug |
|
||||||
|
| D2-5 | FrontmatterRoundtrip invariant | post.allium:223 | Write test: write file, read back, assert all DB fields match |
|
||||||
|
| D2-6 | SidecarRoundtrip invariant | media.allium:198 | Write test: write sidecar, read back, assert all fields match |
|
||||||
|
| D2-7 | ConditionalPostFields: nil fields absent from frontmatter | frontmatter.allium:398 | Write test: post with nil excerpt/author/language → fields not in file |
|
||||||
|
| D2-8 | ConditionalMediaFields: nil fields absent from sidecar | frontmatter.allium:417 | Write test: media with nil title/alt → fields not in sidecar |
|
||||||
|
| D2-9 | max_posts_per_page 1..500 constraint | metadata.allium:75-77 | Write test: values outside range rejected |
|
||||||
|
| D2-10 | SandboxedExecution: restricted capabilities blocked | script.allium:84-88 | Write test: filesystem/process/package loading blocked |
|
||||||
|
| D2-11 | TransformToastBudget enforcement | script.allium:251-258 | Write test: per-script and total toast limits enforced |
|
||||||
|
| D2-12 | ProgressThrottled: 250ms throttle | task.allium:110-113 | Write test: rapid progress reports throttled |
|
||||||
|
| D2-13 | archived→draft transition | post.allium:121 | Write test: unarchive post → draft |
|
||||||
|
| D2-14 | archived→published transition | post.allium:122 | Write test: unarchive post → published |
|
||||||
|
| D2-15 | AppNoopNotifier: app writes don't produce notification rows | cli_sync.allium:64-68 | Write test: app mutation produces no notification row |
|
||||||
|
| D2-16 | ValidateMedia rule | media_processing.allium:318-343 | Write test: missing/corrupted/orphan media detected |
|
||||||
|
| D2-17 | ContentHashSkipsUnchanged during reindex | embedding.allium:199-202 | Write test: unchanged content_hash skips re-embedding |
|
||||||
|
|
||||||
|
### D3. Partial Test Coverage (needs expansion)
|
||||||
|
|
||||||
|
| ID | Claim | Spec | Gap | Path |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| D3-1 | PublishPost: content=null after publish | post.allium:186 | Not explicitly tested | Add assertion |
|
||||||
|
| D3-2 | PublishPost: old file deleted on path change | engine_side_effects.allium:73-74 | Not tested | Add test |
|
||||||
|
| D3-3 | UpsertPostTranslation: do_not_translate guard | translation.allium:113 | Indirectly covered only | Add direct test |
|
||||||
|
| D3-4 | PublishTemplate: Liquid validation prerequisite | template.allium:139 | Not tested as publish gate | Add test |
|
||||||
|
| D3-5 | PublishScript: validation prerequisite | script.allium:181 | Not tested as publish gate | Add test |
|
||||||
|
| D3-6 | ExecuteMacro failure degrades to empty | script.allium:199 | Returns error tuple, not empty | Fix code or update spec |
|
||||||
|
| D3-7 | TemplateFrontmatter roundtrip | template.allium:53 | Slug verified, no full parse-back | Add roundtrip test |
|
||||||
|
| D3-8 | DefaultCategories for fresh project | metadata.allium:60 | Defaults present after add, not verified fresh | Add fresh-project test |
|
||||||
|
| D3-9 | FtsIncludesTranslations | translation.allium:178 | Tested for one language; expand | Test all stemmer languages |
|
||||||
|
| D3-10 | PostCanonicalUrl format | post.allium:33-40 | Constructed in links test, not asserted as invariant | Add format assertion |
|
||||||
|
| D3-11 | Slug generation: German transliteration | post.allium:14-22 | "Föö Bär" → "foo-bar-blog" tested; expand ä/ö/ü/ß/ÄÖÜ | Expand test |
|
||||||
|
|
||||||
|
### D4. UI Test Coverage Gaps (whole-editor specs)
|
||||||
|
|
||||||
|
| ID | Spec | Covered | Not Covered |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D4-1 | editor_media.allium | AI analysis, delete | Translate, replace file, link-to-post, translation CRUD, detect language |
|
||||||
|
| D4-2 | editor_settings.allium | AI endpoints, airplane toggle, rebuild | Protected categories, MCP agents, style/theme, search filter, categories CRUD |
|
||||||
|
| D4-3 | editor_chat.allium | Chat creation, pinned tab | API key screen, message rendering, input area, model selector, inline surfaces |
|
||||||
|
| D4-4 | editor_script.allium | Editor layout, create defaults | Save, syntax check, run, delete |
|
||||||
|
| D4-5 | editor_template.allium | Editor layout, create defaults | Save with validation, validate, delete with references |
|
||||||
|
| D4-6 | editor_tags.allium | Sync/discover, merge | Cloud sizing, color picker, delete confirmation, create form |
|
||||||
|
| D4-7 | editor_misc.allium | Menu add/save, metadata diff, validation | Menu protection, import analysis, translation fix, duplicate dismiss, git diff |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Order for Resolution
|
||||||
|
|
||||||
|
1. **A1-1 through A1-14c** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown, real embedding model, HNSW ANN index; only A1-14c = Apple GPU/EMLX acceleration still open)
|
||||||
|
2. **D1-1 through D1-18** — untested invariants/guarantees
|
||||||
|
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
|
||||||
|
4. **B1-1 through B1-6** — major code behaviors missing from spec
|
||||||
|
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
|
||||||
|
6. **D2-1 through D2-17** — untested rules
|
||||||
|
7. **D3-1 through D3-11** — partial test coverage
|
||||||
|
8. **B1-7 through B1-20** — minor code behaviors missing from spec
|
||||||
|
9. **D4-1 through D4-7** — UI test coverage
|
||||||
87
TESTAUDIT.md
Normal file
87
TESTAUDIT.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Test Audit Procedure
|
||||||
|
|
||||||
|
Periodic review of the unit test suite to ensure every test exercises production
|
||||||
|
code against real assumptions and behavior.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
All `*_test.exs` files under `test/`.
|
||||||
|
|
||||||
|
## What counts as a valid unit test
|
||||||
|
|
||||||
|
A valid unit test **calls at least one production function** from `lib/bds/` and
|
||||||
|
**asserts on its return value, side effects, or observable behavior**.
|
||||||
|
|
||||||
|
Acceptable patterns:
|
||||||
|
|
||||||
|
- Calling a production function and asserting its return value.
|
||||||
|
- Calling a production function with injected test doubles (fake HTTP clients,
|
||||||
|
fake runtimes) and asserting the production code's orchestration logic.
|
||||||
|
- Mounting a LiveView or rendering a LiveComponent and asserting HTML output
|
||||||
|
or database state after interactions.
|
||||||
|
- Sending events to a GenServer and asserting state transitions.
|
||||||
|
|
||||||
|
### Source-property tests (acceptable, not flagged)
|
||||||
|
|
||||||
|
Tests that verify structural properties of source code are acceptable and should
|
||||||
|
not be flagged during this audit. Examples:
|
||||||
|
|
||||||
|
- Checking that all public functions have `@spec` annotations (AST parsing).
|
||||||
|
- Asserting absence of `String.to_atom` or `cond do` in specific files.
|
||||||
|
- Verifying CSS/JS/template assets contain expected class names or imports.
|
||||||
|
- Checking that `API.md` matches the output of a documentation generator.
|
||||||
|
- Verifying database indexes exist via `EXPLAIN QUERY PLAN`.
|
||||||
|
- Asserting `.allium` spec files have consistent parameter signatures.
|
||||||
|
- Checking config files for expected values.
|
||||||
|
- Verifying function decomposition patterns in source.
|
||||||
|
|
||||||
|
These are linting/contract/consistency checks. They serve a purpose but are
|
||||||
|
distinct from behavioral tests.
|
||||||
|
|
||||||
|
## What gets flagged
|
||||||
|
|
||||||
|
1. **Export-existence-only tests** — tests that call `function_exported?/3` or
|
||||||
|
`Code.ensure_loaded?/1` without ever invoking the function. These verify
|
||||||
|
compilation, not behavior. They are redundant when the same module is already
|
||||||
|
tested via rendering or direct calls in another test file.
|
||||||
|
|
||||||
|
2. **Mock-only tests** — tests that define a fake/stub module and only assert
|
||||||
|
on that fake's behavior without routing through any production code path.
|
||||||
|
|
||||||
|
3. **Trivially-passing tests** — tests whose assertions succeed regardless of
|
||||||
|
whether the production code is correct (e.g., asserting on a hardcoded value
|
||||||
|
that never touches production logic).
|
||||||
|
|
||||||
|
## How to run the audit
|
||||||
|
|
||||||
|
Ask Claude Code to:
|
||||||
|
|
||||||
|
> Analyse the unit tests of the project and check if all of them actually call
|
||||||
|
> proper production code or if there are tests that essentially only test
|
||||||
|
> scaffolds, mocks and helper functions. Every unit test must test proper
|
||||||
|
> production code against assumptions and behaviour. Source-property tests
|
||||||
|
> (structure, @spec, asset presence, schema verification, doc staleness) are
|
||||||
|
> acceptable and should not be flagged.
|
||||||
|
|
||||||
|
The audit should:
|
||||||
|
|
||||||
|
1. Read every `*_test.exs` file under `test/` in full.
|
||||||
|
2. For each test block, identify which production function (if any) is called.
|
||||||
|
3. Flag any test that falls into the categories above.
|
||||||
|
4. Report flagged tests with file path, line number, and explanation.
|
||||||
|
|
||||||
|
## Audit log
|
||||||
|
|
||||||
|
### 2026-05-11
|
||||||
|
|
||||||
|
Reviewed all 71 test files (69 after cleanup). Found 2 redundant files:
|
||||||
|
|
||||||
|
- `test/bds/desktop/shell_live/chat_editor_test.exs` — single test only called
|
||||||
|
`function_exported?` for `ChatEditor`. The component was already fully tested
|
||||||
|
via `render_component` in `shell_live_test.exs`. **Deleted.**
|
||||||
|
|
||||||
|
- `test/bds/desktop/shell_live/import_editor_test.exs` — single test only called
|
||||||
|
`Code.ensure_loaded?` + `function_exported?` for `ImportEditor`. The component
|
||||||
|
was already exercised in `import_shell_live_test.exs`. **Deleted.**
|
||||||
|
|
||||||
|
Result after cleanup: 646 tests, 0 failures, 4 skipped.
|
||||||
@@ -16,4 +16,5 @@
|
|||||||
@import "./menu_editor.css";
|
@import "./menu_editor.css";
|
||||||
@import "./media_editor.css";
|
@import "./media_editor.css";
|
||||||
@import "./import_editor.css";
|
@import "./import_editor.css";
|
||||||
|
@import "./misc_editor.css";
|
||||||
@import "./utilities.css";
|
@import "./utilities.css";
|
||||||
@@ -86,10 +86,11 @@
|
|||||||
.chat-message {
|
.chat-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message.user {
|
.chat-message.user {
|
||||||
justify-content: flex-end;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-content {
|
.chat-message-content {
|
||||||
@@ -102,10 +103,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel .chat-message.user .chat-message-content {
|
.chat-panel .chat-message.user .chat-message-content {
|
||||||
background: transparent;
|
background: var(--vscode-button-background, var(--accent-color, #007acc));
|
||||||
color: var(--vscode-list-activeSelectionForeground);
|
color: var(--vscode-button-foreground, var(--vscode-list-activeSelectionForeground, #ffffff));
|
||||||
border: 0;
|
border: 1px solid var(--vscode-button-background, var(--accent-color, #007acc));
|
||||||
padding: 6px 12px;
|
border-radius: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,19 +131,346 @@
|
|||||||
background: var(--vscode-textCodeBlock-background);
|
background: var(--vscode-textCodeBlock-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Inline surfaces (<details> wrappers) ──────────────────────────── */
|
||||||
|
|
||||||
|
.chat-inline-surface {
|
||||||
|
margin: 10px 0;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-sideBar-background);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-header::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-header::marker {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-dismiss {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface:hover .chat-inline-surface-dismiss {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-dismiss:hover {
|
||||||
|
background: var(--vscode-toolbar-hoverBackground);
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-body {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-body h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chart surface ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-chart-type {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-meta span:first-child {
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-meta span:last-child {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
min-width: 0;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-action-button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-action-button:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metric surface ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-metric-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-metric-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── List surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mindmap surface ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-mindmap {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-mindmap li {
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-mindmap li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-mindmap strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-mindmap-children {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-tab-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-tab-button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-tab-button.active {
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
border-bottom-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-tab-button:hover:not(.active) {
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-tab-panel {
|
||||||
|
padding: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-form-field input,
|
||||||
|
.chat-surface-form-field textarea,
|
||||||
|
.chat-surface-form-field select {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-form-field textarea {
|
||||||
|
min-height: 60px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-form-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Table surface wrapper ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-tool-surface-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-panel .chat-input-container {
|
.chat-panel .chat-input-container {
|
||||||
--chat-input-line-height: 20px;
|
--chat-input-line-height: 22px;
|
||||||
--chat-input-min-height: 20px;
|
--chat-input-min-height: 24px;
|
||||||
border-top: 1px solid var(--vscode-panel-border);
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
padding: 8px 16px;
|
padding: 12px 16px;
|
||||||
background: var(--vscode-sideBar-background);
|
background: var(--vscode-sideBar-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel .chat-input-wrapper {
|
.chat-panel .chat-input-wrapper {
|
||||||
min-height: 30px;
|
min-height: 40px;
|
||||||
border: 1px solid var(--vscode-input-border);
|
border: 1px solid var(--vscode-input-border);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 4px 6px;
|
padding: 6px 8px;
|
||||||
background: var(--vscode-input-background);
|
background: var(--vscode-input-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,11 +489,16 @@
|
|||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
outline: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--vscode-input-foreground);
|
color: var(--vscode-input-foreground);
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-panel .chat-input::placeholder {
|
.chat-panel .chat-input::placeholder {
|
||||||
color: var(--vscode-input-placeholderForeground);
|
color: var(--vscode-input-placeholderForeground);
|
||||||
}
|
}
|
||||||
@@ -221,3 +555,88 @@
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Colour picker popover */
|
||||||
|
.colour-picker-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-trigger {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-trigger:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
width: 196px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-swatch {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: border-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-swatch:hover {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-swatch.selected {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
box-shadow: 0 0 0 1px var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-custom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-custom label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-custom input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|||||||
539
assets/css/misc_editor.css
Normal file
539
assets/css/misc_editor.css
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
/* ── Misc-editor shell (shared by all misc tabs) ──────────────────────── */
|
||||||
|
|
||||||
|
.misc-editor-shell {
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-header {
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
background: var(--vscode-tab-activeBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-header p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-summary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Summary pills ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.misc-summary-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-summary-pill span {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-summary-pill strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Misc card (used by site-validation, empty states) ───────────────── */
|
||||||
|
|
||||||
|
.misc-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-card ul {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Misc columns (site-validation 3-column layout) ──────────────────── */
|
||||||
|
|
||||||
|
.misc-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Misc list (find-duplicates) ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.misc-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-list-item:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-pair-row label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-pair-row .linkish {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-textLink-foreground, #3794ff);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-pair-row .linkish:hover {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata-diff: tab bar ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.metadata-diff-tool {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-tab-inactiveForeground, var(--vscode-descriptionForeground));
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tab:hover {
|
||||||
|
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tab.active {
|
||||||
|
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||||
|
border-bottom-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--vscode-activityBarBadge-background, #007acc);
|
||||||
|
color: var(--vscode-activityBarBadge-foreground, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata-diff: field pills ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.metadata-diff-field-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill.active {
|
||||||
|
border-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill-toggle:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-pill-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-pill-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-left: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-action-button {
|
||||||
|
font-size: 11px !important;
|
||||||
|
padding: 2px 8px !important;
|
||||||
|
min-height: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata-diff: results area ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.metadata-diff-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-empty p {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Diff item cards (shared by metadata-diff and orphan sections) ──── */
|
||||||
|
|
||||||
|
.diff-item-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-card {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-card.orphan-file {
|
||||||
|
border-left: 3px solid var(--vscode-editorWarning-foreground, #cca700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, transparent);
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-header strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-fields {
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-values {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-value.db-value {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-value.file-value {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-source-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 28px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-value .diff-source-label {
|
||||||
|
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 22%, transparent);
|
||||||
|
color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-value .diff-source-label {
|
||||||
|
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 22%, transparent);
|
||||||
|
color: var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Orphan files section ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.orphan-files-section {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 35%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 5%, var(--vscode-editor-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-files-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-files-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-files-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-path span {
|
||||||
|
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Translation validation ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.translation-validation-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-summary {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-summary p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-section h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-empty {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-db {
|
||||||
|
border-left: 3px solid var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-file {
|
||||||
|
border-left: 3px solid var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 3px 12px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-meta dt {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Git diff ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-diff-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-empty {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-toolbar label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-toolbar select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@
|
|||||||
.confirm-delete-modal,
|
.confirm-delete-modal,
|
||||||
.confirm-dialog,
|
.confirm-dialog,
|
||||||
.gallery-overlay-content {
|
.gallery-overlay-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
border: 1px solid #3c3c3c;
|
border: 1px solid #3c3c3c;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
45
assets/js/hooks/colour_picker.js
Normal file
45
assets/js/hooks/colour_picker.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export const ColourPicker = {
|
||||||
|
mounted() {
|
||||||
|
this._onClickAway = (e) => {
|
||||||
|
if (!this.el.contains(e.target)) {
|
||||||
|
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", this._onClickAway);
|
||||||
|
|
||||||
|
this._setupCustomInput();
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
this._setupCustomInput();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
document.removeEventListener("mousedown", this._onClickAway);
|
||||||
|
},
|
||||||
|
|
||||||
|
_setupCustomInput() {
|
||||||
|
const input = this.el.querySelector(".colour-picker-custom input");
|
||||||
|
if (!input || input._cpBound) return;
|
||||||
|
input._cpBound = true;
|
||||||
|
|
||||||
|
const pushColor = () => {
|
||||||
|
let val = input.value.trim();
|
||||||
|
if (val && !val.startsWith("#")) val = "#" + val;
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
|
||||||
|
const event = this.el.dataset.pickEvent;
|
||||||
|
this.pushEventTo(this.el.dataset.target, event, { color: val });
|
||||||
|
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
pushColor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener("blur", pushColor);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import { AppShell } from "./app_shell.js";
|
|||||||
import { SidebarInteractions } from "./sidebar_interactions.js";
|
import { SidebarInteractions } from "./sidebar_interactions.js";
|
||||||
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
|
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
|
||||||
import { ChatSurface } from "./chat_surface.js";
|
import { ChatSurface } from "./chat_surface.js";
|
||||||
|
import { ColourPicker } from "./colour_picker.js";
|
||||||
import { MenuEditorTree } from "./menu_editor_tree.js";
|
import { MenuEditorTree } from "./menu_editor_tree.js";
|
||||||
import { MonacoEditor } from "./monaco_editor.js";
|
import { MonacoEditor } from "./monaco_editor.js";
|
||||||
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
|
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
|
||||||
@@ -12,6 +13,7 @@ export const Hooks = {
|
|||||||
SettingsSectionScroll,
|
SettingsSectionScroll,
|
||||||
TagsSectionScroll,
|
TagsSectionScroll,
|
||||||
ChatSurface,
|
ChatSurface,
|
||||||
|
ColourPicker,
|
||||||
MenuEditorTree,
|
MenuEditorTree,
|
||||||
MonacoEditor,
|
MonacoEditor,
|
||||||
MonacoDiffEditor
|
MonacoDiffEditor
|
||||||
|
|||||||
@@ -61,9 +61,18 @@ config :bds, :scripting,
|
|||||||
job_max_reductions: :none
|
job_max_reductions: :none
|
||||||
|
|
||||||
config :bds, :embeddings,
|
config :bds, :embeddings,
|
||||||
backend: BDS.Embeddings.Backends.InApp,
|
backend: BDS.Embeddings.Backends.Neural,
|
||||||
model_id: "Xenova/multilingual-e5-small",
|
model_id: "Xenova/multilingual-e5-small",
|
||||||
dimensions: 384
|
model_repo: "intfloat/multilingual-e5-small",
|
||||||
|
dimensions: 384,
|
||||||
|
# Inference is batched: batch_size texts per compiled run, truncated to
|
||||||
|
# sequence_length tokens. Tuning these trades throughput against memory.
|
||||||
|
batch_size: 16,
|
||||||
|
sequence_length: 256
|
||||||
|
|
||||||
|
# Cache downloaded model files under the app data directory so they persist
|
||||||
|
# across sessions (ModelCaching invariant). Overridden at runtime in prod.
|
||||||
|
config :bumblebee, :cache_dir, Path.expand("../priv/data/models", __DIR__)
|
||||||
|
|
||||||
config :logger, :console,
|
config :logger, :console,
|
||||||
format: "$time $metadata[$level] $message\n",
|
format: "$time $metadata[$level] $message\n",
|
||||||
|
|||||||
@@ -8,4 +8,9 @@ if config_env() == :prod do
|
|||||||
config :bds, BDS.Repo,
|
config :bds, BDS.Repo,
|
||||||
database: database_path,
|
database: database_path,
|
||||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1")
|
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1")
|
||||||
|
|
||||||
|
# Persist downloaded embedding model files alongside the database data dir.
|
||||||
|
config :bumblebee, :cache_dir,
|
||||||
|
System.get_env("BDS_MODEL_CACHE_DIR") ||
|
||||||
|
Path.join(Path.dirname(Path.expand(database_path)), "models")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,3 +8,13 @@ config :bds, BDS.Repo,
|
|||||||
busy_timeout: 15_000
|
busy_timeout: 15_000
|
||||||
|
|
||||||
config :logger, level: :warning
|
config :logger, level: :warning
|
||||||
|
|
||||||
|
# Tests use the deterministic lexical stub backend so the suite stays offline
|
||||||
|
# and never downloads the ~100 MB neural model.
|
||||||
|
config :bds, :embeddings,
|
||||||
|
backend: BDS.Embeddings.Backends.InApp,
|
||||||
|
model_id: "Xenova/multilingual-e5-small",
|
||||||
|
model_repo: "intfloat/multilingual-e5-small",
|
||||||
|
dimensions: 384,
|
||||||
|
batch_size: 16,
|
||||||
|
sequence_length: 256
|
||||||
|
|||||||
@@ -186,4 +186,12 @@ defmodule BDS.AI do
|
|||||||
|
|
||||||
@spec cancel_chat(String.t()) :: :ok
|
@spec cancel_chat(String.t()) :: :ok
|
||||||
defdelegate cancel_chat(conversation_id), to: Chat
|
defdelegate cancel_chat(conversation_id), to: Chat
|
||||||
|
|
||||||
|
@spec get_surface_state(String.t()) :: map()
|
||||||
|
defdelegate get_surface_state(conversation_id), to: Chat
|
||||||
|
|
||||||
|
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
defdelegate put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces),
|
||||||
|
to: Chat
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,6 +62,42 @@ defmodule BDS.AI.Chat do
|
|||||||
Repo.get(ChatConversation, conversation_id)
|
Repo.get(ChatConversation, conversation_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec get_surface_state(String.t()) :: map()
|
||||||
|
def get_surface_state(conversation_id) when is_binary(conversation_id) do
|
||||||
|
case Repo.get(ChatConversation, conversation_id) do
|
||||||
|
%ChatConversation{surface_state: state} when is_map(state) -> state
|
||||||
|
_other -> %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
def put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces)
|
||||||
|
when is_binary(conversation_id) do
|
||||||
|
case Repo.get(ChatConversation, conversation_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%ChatConversation{} = conversation ->
|
||||||
|
state = %{
|
||||||
|
"surface_data" => surface_data,
|
||||||
|
"surface_tabs" => surface_tabs,
|
||||||
|
"dismissed_surfaces" => MapSet.to_list(dismissed_surfaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation
|
||||||
|
|> ChatConversation.changeset(%{
|
||||||
|
surface_state: state,
|
||||||
|
updated_at: Persistence.now_ms()
|
||||||
|
})
|
||||||
|
|> Repo.update()
|
||||||
|
|> case do
|
||||||
|
{:ok, _updated} -> {:ok, state}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
|
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
|
||||||
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
||||||
case Repo.get(ChatConversation, conversation_id) do
|
case Repo.get(ChatConversation, conversation_id) do
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do
|
|||||||
title: String.t() | nil,
|
title: String.t() | nil,
|
||||||
model: String.t() | nil,
|
model: String.t() | nil,
|
||||||
copilot_session_id: String.t() | nil,
|
copilot_session_id: String.t() | nil,
|
||||||
|
surface_state: map() | nil,
|
||||||
created_at: integer() | nil,
|
created_at: integer() | nil,
|
||||||
updated_at: integer() | nil
|
updated_at: integer() | nil
|
||||||
}
|
}
|
||||||
@@ -19,13 +20,14 @@ defmodule BDS.AI.ChatConversation do
|
|||||||
field :title, :string
|
field :title, :string
|
||||||
field :model, :string
|
field :model, :string
|
||||||
field :copilot_session_id, :string
|
field :copilot_session_id, :string
|
||||||
|
field :surface_state, :map
|
||||||
field :created_at, :integer
|
field :created_at, :integer
|
||||||
field :updated_at, :integer
|
field :updated_at, :integer
|
||||||
end
|
end
|
||||||
|
|
||||||
def changeset(conversation, attrs) do
|
def changeset(conversation, attrs) do
|
||||||
conversation
|
conversation
|
||||||
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at],
|
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
|
||||||
empty_values: [nil]
|
empty_values: [nil]
|
||||||
)
|
)
|
||||||
|> validate_required([:id, :title, :created_at, :updated_at])
|
|> validate_required([:id, :title, :created_at, :updated_at])
|
||||||
|
|||||||
@@ -37,14 +37,24 @@ defmodule BDS.Application do
|
|||||||
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
|
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
|
||||||
BDS.Scripting.JobStore,
|
BDS.Scripting.JobStore,
|
||||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||||
BDS.Scripting.JobSupervisor
|
BDS.Scripting.JobSupervisor,
|
||||||
| desktop_children(current_env())
|
BDS.Embeddings.Index
|
||||||
]
|
] ++ embedding_children() ++ desktop_children(current_env())
|
||||||
|
|
||||||
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||||
Supervisor.start_link(children, opts)
|
Supervisor.start_link(children, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The neural embedding backend runs as a supervised, lazily-initialised
|
||||||
|
# GenServer (it loads the model only on the first embedding request). Only
|
||||||
|
# start it when it is the configured backend.
|
||||||
|
defp embedding_children do
|
||||||
|
case Application.get_env(:bds, :embeddings, [])[:backend] do
|
||||||
|
BDS.Embeddings.Backends.Neural -> [BDS.Embeddings.Backends.Neural]
|
||||||
|
_other -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp current_env do
|
defp current_env do
|
||||||
Application.get_env(:bds, :current_env_override) || @compiled_env
|
Application.get_env(:bds, :current_env_override) || @compiled_env
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ defmodule BDS.Desktop.FilePicker do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def choose_files(prompt, opts \\ []) when is_binary(prompt) do
|
||||||
|
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
|
||||||
|
:cancel
|
||||||
|
else
|
||||||
|
case :os.type() do
|
||||||
|
{:unix, :darwin} -> choose_files_macos(prompt, opts)
|
||||||
|
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp choose_file_macos(prompt) do
|
defp choose_file_macos(prompt) do
|
||||||
script = "POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\")"
|
script = "POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\")"
|
||||||
|
|
||||||
@@ -21,6 +32,50 @@ defmodule BDS.Desktop.FilePicker do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp choose_files_macos(prompt, opts) do
|
||||||
|
multiple = Keyword.get(opts, :multiple, false)
|
||||||
|
image_only = Keyword.get(opts, :image_only, false)
|
||||||
|
|
||||||
|
script_parts = ["POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\""]
|
||||||
|
|
||||||
|
script_parts =
|
||||||
|
if image_only do
|
||||||
|
script_parts ++ [" of type {\"public.image\"}"]
|
||||||
|
else
|
||||||
|
script_parts
|
||||||
|
end
|
||||||
|
|
||||||
|
script_parts =
|
||||||
|
if multiple do
|
||||||
|
script_parts ++ [" with multiple selections allowed"]
|
||||||
|
else
|
||||||
|
script_parts
|
||||||
|
end
|
||||||
|
|
||||||
|
script = Enum.join(script_parts, "") <> ")"
|
||||||
|
|
||||||
|
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
|
||||||
|
{output, 0} -> parse_choose_files_result(String.trim(output), multiple)
|
||||||
|
{output, _status} -> normalize_picker_failure(output)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def parse_choose_files_result(output, true = _multiple) do
|
||||||
|
paths =
|
||||||
|
output
|
||||||
|
|> String.split("\n")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|
||||||
|
{:ok, paths}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def parse_choose_files_result(output, false = _multiple) do
|
||||||
|
{:ok, output}
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_picker_failure(output) do
|
defp normalize_picker_failure(output) do
|
||||||
message = String.trim(output)
|
message = String.trim(output)
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
title: Map.get(context, :insert_media_title, "Insert Media"),
|
title: Map.get(context, :insert_media_title, "Insert Media"),
|
||||||
search_query: "",
|
search_query: "",
|
||||||
results: Enum.map(media, &to_insert_media_result/1),
|
results: Enum.map(media, &to_insert_media_result/1),
|
||||||
all_media: media
|
all_media: media,
|
||||||
|
post_id: current_id(context)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -48,29 +49,32 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def open(:media, :confirm_delete, context) do
|
def open(:media, :confirm_delete, context) do
|
||||||
delete_details = Map.get(context, :delete_details, %{})
|
%{
|
||||||
|
title: title,
|
||||||
|
entity_name: entity_name,
|
||||||
|
entity_type: entity_type,
|
||||||
|
reference_list: reference_list
|
||||||
|
} = context.delete_details
|
||||||
|
|
||||||
%{
|
%{
|
||||||
kind: :confirm_delete,
|
kind: :confirm_delete,
|
||||||
title: Map.get(delete_details, :title, "Delete"),
|
title: title,
|
||||||
entity_name: Map.get(delete_details, :entity_name, ""),
|
entity_name: entity_name,
|
||||||
entity_type: Map.get(delete_details, :entity_type, "media"),
|
entity_type: entity_type,
|
||||||
reference_count: length(Map.get(delete_details, :reference_list, [])),
|
reference_count: length(reference_list),
|
||||||
reference_list: Map.get(delete_details, :reference_list, [])
|
reference_list: reference_list
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
|
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
|
||||||
|
|
||||||
def open(:tags, :confirm_merge, context) do
|
def open(:tags, :confirm_merge, context) do
|
||||||
merge = Map.get(context, :merge_details, %{})
|
%{title: title, message: message} = context.merge_details
|
||||||
target = Map.get(merge, :target, "")
|
|
||||||
count = Map.get(merge, :count, 0)
|
|
||||||
|
|
||||||
%{
|
%{
|
||||||
kind: :confirm_dialog,
|
kind: :confirm_dialog,
|
||||||
title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"),
|
title: title,
|
||||||
message: Map.get(merge, :message, "Cannot be undone.")
|
message: message
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -115,8 +119,8 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
|> Map.get(:all_media, [])
|
|> Map.get(:all_media, [])
|
||||||
|> Enum.filter(fn media ->
|
|> Enum.filter(fn media ->
|
||||||
normalized == "" or
|
normalized == "" or
|
||||||
search_matches?(Map.get(media, :title, ""), normalized) or
|
search_matches?(media.title, normalized) or
|
||||||
search_matches?(Map.get(media, :original_name, ""), normalized)
|
search_matches?(media.original_name, normalized)
|
||||||
end)
|
end)
|
||||||
|> Enum.map(&to_insert_media_result/1)
|
|> Enum.map(&to_insert_media_result/1)
|
||||||
|
|
||||||
@@ -203,18 +207,22 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
def insert_media_result(_overlay, _media_id), do: nil
|
def insert_media_result(_overlay, _media_id), do: nil
|
||||||
|
|
||||||
defp language_picker(context, source_language) do
|
defp language_picker(context, source_language) do
|
||||||
|
existing_translations = Map.get(context, :existing_translations, %{})
|
||||||
|
language_names = Map.get(context, :language_names, %{})
|
||||||
|
language_flags = Map.get(context, :language_flags, %{})
|
||||||
|
|
||||||
targets =
|
targets =
|
||||||
context
|
context
|
||||||
|> Map.get(:blog_languages, [])
|
|> Map.get(:blog_languages, [])
|
||||||
|> Enum.uniq()
|
|> Enum.uniq()
|
||||||
|> Enum.reject(&(&1 == source_language))
|
|> Enum.reject(&(&1 == source_language))
|
||||||
|> Enum.map(fn code ->
|
|> Enum.map(fn code ->
|
||||||
existing_status = Map.get(Map.get(context, :existing_translations, %{}), code)
|
existing_status = Map.get(existing_translations, code)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
code: code,
|
code: code,
|
||||||
name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)),
|
name: Map.get(language_names, code, String.upcase(code)),
|
||||||
flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code),
|
flag_emoji: Map.get(language_flags, code, code),
|
||||||
has_existing_translation: not is_nil(existing_status),
|
has_existing_translation: not is_nil(existing_status),
|
||||||
existing_status: existing_status
|
existing_status: existing_status
|
||||||
}
|
}
|
||||||
@@ -255,14 +263,15 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
def set_ai_suggestions_error(overlay, _error_message), do: overlay
|
def set_ai_suggestions_error(overlay, _error_message), do: overlay
|
||||||
|
|
||||||
defp normalize_ai_fields(fields) do
|
defp normalize_ai_fields(fields) do
|
||||||
Enum.map(fields, fn field ->
|
Enum.map(fields, fn %{key: key, label: label, current_value: current,
|
||||||
|
suggested_value: suggested, locked: locked} = field ->
|
||||||
%{
|
%{
|
||||||
key: to_string(Map.get(field, :key, "")),
|
key: to_string(key),
|
||||||
label: Map.get(field, :label, ""),
|
label: label,
|
||||||
current_value: Map.get(field, :current_value, ""),
|
current_value: current,
|
||||||
suggested_value: Map.get(field, :suggested_value, ""),
|
suggested_value: suggested,
|
||||||
accepted: not Map.get(field, :locked, false),
|
accepted: not locked,
|
||||||
locked: Map.get(field, :locked, false),
|
locked: locked,
|
||||||
loading: Map.get(field, :loading, false)
|
loading: Map.get(field, :loading, false)
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
@@ -276,7 +285,7 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp gallery_images(context) do
|
defp gallery_images(context) do
|
||||||
images = Enum.filter(Map.get(context, :media, []), &Map.get(&1, :is_image, false))
|
images = Enum.filter(Map.get(context, :media, []), & &1.is_image)
|
||||||
post_media_ids = Map.get(context, :post_media_ids, [])
|
post_media_ids = Map.get(context, :post_media_ids, [])
|
||||||
|
|
||||||
case Enum.filter(images, &(&1.id in post_media_ids)) do
|
case Enum.filter(images, &(&1.id in post_media_ids)) do
|
||||||
@@ -289,29 +298,29 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
%{
|
%{
|
||||||
post_id: post.id,
|
post_id: post.id,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
status: to_string(Map.get(post, :status, "draft")),
|
status: post.status,
|
||||||
canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"),
|
canonical_url: post.canonical_url,
|
||||||
similarity_score: Map.get(post, :similarity_score)
|
similarity_score: nil
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp to_insert_media_result(media) do
|
defp to_insert_media_result(media) do
|
||||||
%{
|
%{
|
||||||
media_id: media.id,
|
media_id: media.id,
|
||||||
title: Map.get(media, :title, ""),
|
title: media.title,
|
||||||
original_name: Map.get(media, :original_name, media.id),
|
original_name: media.original_name,
|
||||||
is_image: Map.get(media, :is_image, false),
|
is_image: media.is_image,
|
||||||
thumbnail_url: Map.get(media, :thumbnail_url)
|
thumbnail_url: media.thumbnail_url
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp to_gallery_image(media) do
|
defp to_gallery_image(media) do
|
||||||
%{
|
%{
|
||||||
media_id: media.id,
|
media_id: media.id,
|
||||||
thumbnail_url: Map.get(media, :thumbnail_url),
|
thumbnail_url: media.thumbnail_url,
|
||||||
image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)),
|
image_url: media.image_url,
|
||||||
alt_text: Map.get(media, :alt_text),
|
alt_text: media.alt_text,
|
||||||
title: Map.get(media, :title, Map.get(media, :original_name, media.id))
|
title: media.title
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -120,16 +120,7 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
"rebuild_embedding_index",
|
"rebuild_embedding_index",
|
||||||
"Rebuild Embedding Index",
|
"Rebuild Embedding Index",
|
||||||
"Embeddings",
|
"Embeddings",
|
||||||
fn report ->
|
fn report -> rebuild_embedding_index_work(project, report) end
|
||||||
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
|
|
||||||
report.(1.0, "Embedding index rebuilt")
|
|
||||||
|
|
||||||
%{
|
|
||||||
project_id: project.id,
|
|
||||||
rebuilt_post_ids: rebuilt_post_ids,
|
|
||||||
rebuilt_count: length(rebuilt_post_ids)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -449,7 +440,7 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp translation_fill_enabled?(metadata) do
|
defp translation_fill_enabled?(metadata) do
|
||||||
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, []))
|
([metadata.main_language] ++ metadata.blog_languages)
|
||||||
|> Enum.map(fn language ->
|
|> Enum.map(fn language ->
|
||||||
language
|
language
|
||||||
|> to_string()
|
|> to_string()
|
||||||
@@ -524,8 +515,14 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Rebuild Embedding Index",
|
name: "Rebuild Embedding Index",
|
||||||
work: fn report ->
|
work: fn report -> rebuild_embedding_index_work(project, report) end
|
||||||
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp rebuild_embedding_index_work(project, report) do
|
||||||
|
case Embeddings.rebuild_project(project.id, on_progress: report) do
|
||||||
|
{:ok, rebuilt_post_ids} ->
|
||||||
report.(1.0, "Embedding index rebuilt")
|
report.(1.0, "Embedding index rebuilt")
|
||||||
|
|
||||||
%{
|
%{
|
||||||
@@ -533,9 +530,22 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
rebuilt_post_ids: rebuilt_post_ids,
|
rebuilt_post_ids: rebuilt_post_ids,
|
||||||
rebuilt_count: length(rebuilt_post_ids)
|
rebuilt_count: length(rebuilt_post_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, embedding_error_message(reason)}
|
||||||
end
|
end
|
||||||
}
|
end
|
||||||
]
|
|
||||||
|
defp embedding_error_message(reason) do
|
||||||
|
detail =
|
||||||
|
case reason do
|
||||||
|
message when is_binary(message) -> message
|
||||||
|
{:embedding_backend_unavailable, _inner} -> "the embedding service did not start"
|
||||||
|
other -> inspect(other)
|
||||||
|
end
|
||||||
|
|
||||||
|
"Could not build the embedding index: #{detail}. The model is downloaded on first use, " <>
|
||||||
|
"so check your internet connection — or turn off semantic similarity in Settings."
|
||||||
end
|
end
|
||||||
|
|
||||||
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
|
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
import Phoenix.HTML
|
import Phoenix.HTML
|
||||||
|
|
||||||
alias BDS.{AI, BoundedAtoms}
|
alias BDS.{AI, BoundedAtoms, Metadata}
|
||||||
alias BDS.CliSync.Watcher
|
alias BDS.CliSync.Watcher
|
||||||
alias BDS.Desktop.{ExternalLinks, FolderPicker, ShellData, UILocale}
|
alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
|
||||||
|
|
||||||
alias BDS.Desktop.ShellLive.{
|
alias BDS.Desktop.ShellLive.{
|
||||||
Bridges,
|
Bridges,
|
||||||
ChatEditor,
|
ChatEditor,
|
||||||
|
GalleryImport,
|
||||||
ImportEditor,
|
ImportEditor,
|
||||||
MediaEditor,
|
MediaEditor,
|
||||||
MenuEditor,
|
MenuEditor,
|
||||||
@@ -83,6 +84,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
"load_more_sidebar"
|
"load_more_sidebar"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@git_action_events ["git_fetch", "git_pull", "git_push", "git_prune_lfs"]
|
||||||
|
|
||||||
@layout_menu_actions MapSet.new([
|
@layout_menu_actions MapSet.new([
|
||||||
:toggle_sidebar,
|
:toggle_sidebar,
|
||||||
:toggle_panel,
|
:toggle_panel,
|
||||||
@@ -175,6 +178,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:output_entries, [])
|
|> assign(:output_entries, [])
|
||||||
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
||||||
|> assign(:panel_git_entries, [])
|
|> assign(:panel_git_entries, [])
|
||||||
|
|> assign(:auto_save_timers, %{})
|
||||||
|> reload_shell(workbench)
|
|> reload_shell(workbench)
|
||||||
|> apply_url_params(params)
|
|> apply_url_params(params)
|
||||||
|> tap(&sync_menu_bar_locale/1)}
|
|> tap(&sync_menu_bar_locale/1)}
|
||||||
@@ -190,7 +194,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("toggle_assistant_sidebar", _params, socket) do
|
def handle_event("toggle_assistant_sidebar", _params, socket) do
|
||||||
{:noreply, refresh_layout(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
|
{:noreply,
|
||||||
|
refresh_layout(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("select_view", %{"view" => view_id}, socket) do
|
def handle_event("select_view", %{"view" => view_id}, socket) do
|
||||||
@@ -235,6 +240,20 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
SidebarEvents.handle(socket, event, params, &refresh_sidebar/2)
|
SidebarEvents.handle(socket, event, params, &refresh_sidebar/2)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event(event, _params, socket) when event in @git_action_events do
|
||||||
|
{:noreply, run_git_action(socket, event)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("git_commit", params, socket) do
|
||||||
|
message = params |> get_in(["git", "message"]) |> to_string() |> String.trim()
|
||||||
|
{:noreply, commit_git(socket, message)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("git_initialize", params, socket) do
|
||||||
|
remote_url = params |> get_in(["git", "remote_url"]) |> normalize_git_remote_url()
|
||||||
|
{:noreply, initialize_git(socket, remote_url)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
|
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
|
||||||
{:noreply, create_sidebar_item(socket, kind)}
|
{:noreply, create_sidebar_item(socket, kind)}
|
||||||
end
|
end
|
||||||
@@ -251,6 +270,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
|
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
|
||||||
|
socket = auto_save_current_post(socket)
|
||||||
|
|
||||||
workbench =
|
workbench =
|
||||||
Workbench.open_tab(
|
Workbench.open_tab(
|
||||||
socket.assigns.workbench,
|
socket.assigns.workbench,
|
||||||
@@ -269,6 +290,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
|
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
|
||||||
|
socket = auto_save_current_post(socket)
|
||||||
|
|
||||||
type_atom = BoundedAtoms.editor_route(type, :post)
|
type_atom = BoundedAtoms.editor_route(type, :post)
|
||||||
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
|
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
|
||||||
tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id})
|
tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id})
|
||||||
@@ -399,6 +422,43 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
def handle_event("overlay_lightbox_next", params, socket),
|
def handle_event("overlay_lightbox_next", params, socket),
|
||||||
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
|
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
|
||||||
|
|
||||||
|
def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do
|
||||||
|
if socket.assigns.offline_mode do
|
||||||
|
{:noreply,
|
||||||
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
dgettext("ui", "Add Gallery Images"),
|
||||||
|
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
|
||||||
|
nil,
|
||||||
|
"info"
|
||||||
|
)}
|
||||||
|
else
|
||||||
|
project_id = socket.assigns.projects.active_project_id
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
|
concurrency_limit = metadata.image_import_concurrency
|
||||||
|
language = metadata.main_language || "en"
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
||||||
|
case FilePicker.choose_files(dgettext("ui", "Add Gallery Images"),
|
||||||
|
image_only: true,
|
||||||
|
multiple: true
|
||||||
|
) do
|
||||||
|
{:ok, paths} when is_list(paths) and paths != [] ->
|
||||||
|
GalleryImport.start(paths, project_id, post_id, language, concurrency_limit, parent)
|
||||||
|
|
||||||
|
:cancel ->
|
||||||
|
send(parent, {:add_images_cancelled})
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
send(parent, {:add_images_error, reason})
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :gallery_import_post_id, post_id)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("toggle_project_menu", _params, socket) do
|
def handle_event("toggle_project_menu", _params, socket) do
|
||||||
{:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)}
|
{:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)}
|
||||||
end
|
end
|
||||||
@@ -580,6 +640,83 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
OverlayManager.handle_info({:ai_suggestions_error, type, id, reason}, socket)
|
OverlayManager.handle_info({:ai_suggestions_error, type, id, reason}, socket)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({:add_image_processed, title}, socket) do
|
||||||
|
{:noreply,
|
||||||
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
dgettext("ui", "Add Gallery Images"),
|
||||||
|
dgettext("ui", "Added %{title}", title: title),
|
||||||
|
nil,
|
||||||
|
"info"
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:add_images_complete, count}, socket) do
|
||||||
|
post_id = socket.assigns[:gallery_import_post_id]
|
||||||
|
|
||||||
|
socket =
|
||||||
|
if is_binary(post_id) do
|
||||||
|
send_update(PostEditor,
|
||||||
|
id: "post-editor-#{post_id}",
|
||||||
|
action: :insert_content,
|
||||||
|
content: "\n[[gallery]]\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
send_update(PostEditor,
|
||||||
|
id: "post-editor-#{post_id}",
|
||||||
|
action: :refresh
|
||||||
|
)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:gallery_import_post_id, nil)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> append_output_entry(
|
||||||
|
dgettext("ui", "Add Gallery Images"),
|
||||||
|
dgettext("ui", "Added %{count} images to post", count: count),
|
||||||
|
nil,
|
||||||
|
"info"
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:add_images_error, reason}, socket) do
|
||||||
|
{:noreply,
|
||||||
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
dgettext("ui", "Add Gallery Images"),
|
||||||
|
inspect(reason),
|
||||||
|
nil,
|
||||||
|
"error"
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:add_image_error, path, reason}, socket) do
|
||||||
|
{:noreply,
|
||||||
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
dgettext("ui", "Add Gallery Images"),
|
||||||
|
dgettext("ui", "Failed to process %{path}: %{reason}",
|
||||||
|
path: Path.basename(path),
|
||||||
|
reason: inspect(reason)
|
||||||
|
),
|
||||||
|
nil,
|
||||||
|
"error"
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:add_images_cancelled}, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:test_ping, caller, ref}, socket) do
|
||||||
|
send(caller, {:test_pong, ref})
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info(message, socket) do
|
def handle_info(message, socket) do
|
||||||
Bridges.handle_info(message, socket, bridges_callbacks())
|
Bridges.handle_info(message, socket, bridges_callbacks())
|
||||||
end
|
end
|
||||||
@@ -593,13 +730,17 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
defp refresh_layout(socket, workbench) do
|
defp refresh_layout(socket, workbench) do
|
||||||
git_badge_count = socket.assigns[:git_badge_count] || 0
|
git_badge_count = socket.assigns[:git_badge_count] || 0
|
||||||
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
|
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
|
||||||
task_status = socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
|
|
||||||
|
task_status =
|
||||||
|
socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
|
||||||
|
|
||||||
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
|
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
|
||||||
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
||||||
offline_mode = Map.get(socket.assigns, :offline_mode, true)
|
offline_mode = Map.get(socket.assigns, :offline_mode, true)
|
||||||
sidebar_data = socket.assigns[:sidebar_data] || %{}
|
sidebar_data = socket.assigns[:sidebar_data] || %{}
|
||||||
current_tab = current_tab(workbench)
|
current_tab = current_tab(workbench)
|
||||||
prev_tab = socket.assigns[:current_tab]
|
prev_tab = socket.assigns[:current_tab]
|
||||||
|
|
||||||
prev_panel_tab =
|
prev_panel_tab =
|
||||||
case socket.assigns[:workbench] do
|
case socket.assigns[:workbench] do
|
||||||
%Workbench{panel: %{active_tab: tab}} -> tab
|
%Workbench{panel: %{active_tab: tab}} -> tab
|
||||||
@@ -914,6 +1055,122 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> push_url_state()
|
|> push_url_state()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp run_git_action(socket, event) do
|
||||||
|
project_id = current_project_id(socket)
|
||||||
|
|
||||||
|
{label, result} =
|
||||||
|
case event do
|
||||||
|
"git_fetch" -> {dgettext("ui", "Fetch"), git_call(project_id, &BDS.Git.fetch/1)}
|
||||||
|
"git_pull" -> {dgettext("ui", "Pull"), git_call(project_id, &BDS.Git.pull/1)}
|
||||||
|
"git_push" -> {dgettext("ui", "Push"), git_call(project_id, &BDS.Git.push/1)}
|
||||||
|
"git_prune_lfs" -> {dgettext("ui", "Prune LFS"), prune_lfs(project_id)}
|
||||||
|
end
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> append_git_result(label, result)
|
||||||
|
|> refresh_sidebar(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp commit_git(socket, "") do
|
||||||
|
socket
|
||||||
|
|> append_output_entry(
|
||||||
|
dgettext("ui", "Commit"),
|
||||||
|
dgettext("ui", "Commit message is required"),
|
||||||
|
nil,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
|> refresh_sidebar(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp commit_git(socket, message) do
|
||||||
|
case git_call(current_project_id(socket), &BDS.Git.commit_all(&1, message)) do
|
||||||
|
{:ok, _result} ->
|
||||||
|
workbench = close_git_diff_tabs(socket.assigns.workbench)
|
||||||
|
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:tab_meta, tab_meta)
|
||||||
|
|> append_output_entry(dgettext("ui", "Commit"), message)
|
||||||
|
|> refresh_sidebar(workbench)
|
||||||
|
|> push_url_state()
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output_entry(dgettext("ui", "Commit"), format_git_error(reason), nil, "error")
|
||||||
|
|> refresh_sidebar(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp initialize_git(socket, remote_url) do
|
||||||
|
project_id = current_project_id(socket)
|
||||||
|
|
||||||
|
case git_call(project_id, &BDS.Git.initialize_repo/1) do
|
||||||
|
{:ok, _repo} ->
|
||||||
|
_ = maybe_set_git_remote(project_id, remote_url)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> append_output_entry(
|
||||||
|
dgettext("ui", "Initialize Git"),
|
||||||
|
dgettext("ui", "Repository initialized")
|
||||||
|
)
|
||||||
|
|> refresh_sidebar(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output_entry(
|
||||||
|
dgettext("ui", "Initialize Git"),
|
||||||
|
format_git_error(reason),
|
||||||
|
nil,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
|> refresh_sidebar(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp git_call(nil, _fun), do: {:error, :no_project}
|
||||||
|
defp git_call("default", _fun), do: {:error, :no_project}
|
||||||
|
defp git_call(project_id, fun) when is_binary(project_id), do: fun.(project_id)
|
||||||
|
|
||||||
|
defp prune_lfs(nil), do: {:error, :no_project}
|
||||||
|
defp prune_lfs("default"), do: {:error, :no_project}
|
||||||
|
|
||||||
|
defp prune_lfs(project_id) when is_binary(project_id),
|
||||||
|
do: BDS.Git.prune_lfs_cache(project_id, 10)
|
||||||
|
|
||||||
|
defp maybe_set_git_remote(_project_id, nil), do: :ok
|
||||||
|
|
||||||
|
defp maybe_set_git_remote(project_id, remote_url),
|
||||||
|
do: BDS.Git.set_remote(project_id, remote_url)
|
||||||
|
|
||||||
|
defp append_git_result(socket, label, {:ok, _result}) do
|
||||||
|
append_output_entry(socket, label, dgettext("ui", "Done"))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp append_git_result(socket, label, {:error, reason}) do
|
||||||
|
append_output_entry(socket, label, format_git_error(reason), nil, "error")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_git_error(:no_project), do: dgettext("ui", "No active project")
|
||||||
|
defp format_git_error(%{message: message}) when is_binary(message), do: message
|
||||||
|
defp format_git_error(%{guidance: guidance}) when is_binary(guidance), do: guidance
|
||||||
|
defp format_git_error({:git_failed, message}) when is_binary(message), do: message
|
||||||
|
defp format_git_error(reason), do: inspect(reason)
|
||||||
|
|
||||||
|
defp close_git_diff_tabs(workbench) do
|
||||||
|
workbench.tabs
|
||||||
|
|> Enum.filter(&(&1.type == :git_diff))
|
||||||
|
|> Enum.reduce(workbench, fn tab, wb -> Workbench.close_tab(wb, :git_diff, tab.id) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
|
||||||
|
|
||||||
|
defp normalize_git_remote_url(value) do
|
||||||
|
case value |> to_string() |> String.trim() do
|
||||||
|
"" -> nil
|
||||||
|
url -> url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp sidebar_create_action(view), do: SidebarCreate.action(view)
|
defp sidebar_create_action(view), do: SidebarCreate.action(view)
|
||||||
|
|
||||||
defp set_page_language(socket, language) do
|
defp set_page_language(socket, language) do
|
||||||
@@ -1045,6 +1302,18 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
|
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
|
||||||
|
|
||||||
|
defp auto_save_current_post(
|
||||||
|
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
|
||||||
|
) do
|
||||||
|
if Workbench.dirty?(workbench, :post, post_id) do
|
||||||
|
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||||
|
end
|
||||||
|
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
defp auto_save_current_post(socket), do: socket
|
||||||
|
|
||||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
||||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||||
socket
|
socket
|
||||||
|
|||||||
@@ -47,6 +47,40 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
{:noreply, assign(socket, :workbench, workbench)}
|
{:noreply, assign(socket, :workbench, workbench)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@default_auto_save_delay 3000
|
||||||
|
|
||||||
|
def handle_info({:schedule_auto_save, type, id}, socket, _callbacks) do
|
||||||
|
timers = socket.assigns[:auto_save_timers] || %{}
|
||||||
|
key = {type, id}
|
||||||
|
|
||||||
|
case Map.get(timers, key) do
|
||||||
|
nil -> :ok
|
||||||
|
old_ref -> Process.cancel_timer(old_ref)
|
||||||
|
end
|
||||||
|
|
||||||
|
delay = Application.get_env(:bds, :auto_save_delay, @default_auto_save_delay)
|
||||||
|
ref = Process.send_after(self(), {:auto_save_fire, type, id}, delay)
|
||||||
|
{:noreply, assign(socket, :auto_save_timers, Map.put(timers, key, ref))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:cancel_auto_save, type, id}, socket, _callbacks) do
|
||||||
|
timers = socket.assigns[:auto_save_timers] || %{}
|
||||||
|
key = {type, id}
|
||||||
|
|
||||||
|
case Map.get(timers, key) do
|
||||||
|
nil -> :ok
|
||||||
|
old_ref -> Process.cancel_timer(old_ref)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, key))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:auto_save_fire, :post, post_id}, socket, _callbacks) do
|
||||||
|
timers = socket.assigns[:auto_save_timers] || %{}
|
||||||
|
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||||
|
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, {:post, post_id}))}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info({:editor_command, action, params}, socket, callbacks) do
|
def handle_info({:editor_command, action, params}, socket, callbacks) do
|
||||||
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
|
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
|
||||||
end
|
end
|
||||||
@@ -116,6 +150,15 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
|
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({:persist_surface_state, conversation_id}, socket, _callbacks) do
|
||||||
|
send_update(ChatEditor,
|
||||||
|
id: "chat-editor-#{conversation_id}",
|
||||||
|
action: :persist_surface_state
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
|
def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
use Phoenix.LiveComponent
|
use Phoenix.LiveComponent
|
||||||
|
|
||||||
import Phoenix.HTML, only: [raw: 1]
|
import Phoenix.HTML, only: [raw: 1]
|
||||||
@@ -37,6 +39,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
{:ok, do_note_streaming_content(socket, content)}
|
{:ok, do_note_streaming_content(socket, content)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update(%{action: :persist_surface_state}, socket) do
|
||||||
|
{:ok, persist_surface_state(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -97,7 +103,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
socket
|
socket
|
||||||
) do
|
) do
|
||||||
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
|
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
|
||||||
{:noreply, assign(socket, :surface_data, next_data) |> build_data()}
|
{:noreply, assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(
|
def handle_event(
|
||||||
@@ -111,6 +117,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
:surface_tabs,
|
:surface_tabs,
|
||||||
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
|
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
|
||||||
)
|
)
|
||||||
|
|> persist_surface_state()
|
||||||
|> build_data()
|
|> build_data()
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
@@ -120,6 +127,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|
||||||
|
|> persist_surface_state()
|
||||||
|> build_data()
|
|> build_data()
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
@@ -148,14 +156,29 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
defp ensure_state(socket) do
|
defp ensure_state(socket) do
|
||||||
conversation_id = socket.assigns.current_tab.id
|
conversation_id = socket.assigns.current_tab.id
|
||||||
|
|
||||||
|
persisted = AI.get_surface_state(conversation_id)
|
||||||
|
|
||||||
|
{surface_data, surface_tabs, dismissed_surfaces} =
|
||||||
|
case persisted do
|
||||||
|
state when is_map(state) and map_size(state) > 0 ->
|
||||||
|
{
|
||||||
|
state["surface_data"] || %{},
|
||||||
|
state["surface_tabs"] || %{},
|
||||||
|
MapSet.new(state["dismissed_surfaces"] || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
{%{}, %{}, MapSet.new()}
|
||||||
|
end
|
||||||
|
|
||||||
defaults = %{
|
defaults = %{
|
||||||
conversation_id: conversation_id,
|
conversation_id: conversation_id,
|
||||||
input: "",
|
input: "",
|
||||||
model_selector_open?: false,
|
model_selector_open?: false,
|
||||||
request: nil,
|
request: nil,
|
||||||
surface_data: %{},
|
surface_data: surface_data,
|
||||||
surface_tabs: %{},
|
surface_tabs: surface_tabs,
|
||||||
dismissed_surfaces: MapSet.new(),
|
dismissed_surfaces: dismissed_surfaces,
|
||||||
action_error: nil
|
action_error: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,6 +842,41 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|
|
||||||
# ── Private helpers ───────────────────────────────────────────────────────
|
# ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@surface_state_debounce_ms 500
|
||||||
|
|
||||||
|
defp persist_surface_state(socket) do
|
||||||
|
conversation_id = socket.assigns.conversation_id
|
||||||
|
surface_data = socket.assigns.surface_data
|
||||||
|
surface_tabs = socket.assigns.surface_tabs
|
||||||
|
dismissed_surfaces = socket.assigns.dismissed_surfaces
|
||||||
|
|
||||||
|
case AI.put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces) do
|
||||||
|
{:ok, _state} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Failed to persist surface state for conversation #{conversation_id}",
|
||||||
|
reason: inspect(reason)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
defp schedule_surface_state_persist(socket) do
|
||||||
|
if socket.assigns[:surface_state_timer] do
|
||||||
|
Process.cancel_timer(socket.assigns[:surface_state_timer])
|
||||||
|
end
|
||||||
|
|
||||||
|
timer =
|
||||||
|
Process.send_after(
|
||||||
|
self(),
|
||||||
|
{:persist_surface_state, socket.assigns.conversation_id},
|
||||||
|
@surface_state_debounce_ms
|
||||||
|
)
|
||||||
|
|
||||||
|
assign(socket, :surface_state_timer, timer)
|
||||||
|
end
|
||||||
|
|
||||||
defp active_project_id(socket) do
|
defp active_project_id(socket) do
|
||||||
socket.assigns[:project_id]
|
socket.assigns[:project_id]
|
||||||
|
|||||||
172
lib/bds/desktop/shell_live/gallery_import.ex
Normal file
172
lib/bds/desktop/shell_live/gallery_import.ex
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.GalleryImport do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias BDS.{AI, Media, Metadata}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Starts the image import pipeline: for each selected path, imports the file,
|
||||||
|
runs AI analysis, updates metadata, links to the post, and translates to
|
||||||
|
all configured blog languages.
|
||||||
|
|
||||||
|
Processes images with a concurrency cap via a sliding window.
|
||||||
|
"""
|
||||||
|
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
|
||||||
|
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
|
main_language = metadata.main_language || language
|
||||||
|
blog_languages = metadata.blog_languages || []
|
||||||
|
|
||||||
|
translate_targets =
|
||||||
|
[main_language | blog_languages]
|
||||||
|
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||||
|
|> Enum.uniq()
|
||||||
|
|
||||||
|
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
|
||||||
|
|
||||||
|
tasks =
|
||||||
|
Enum.map(in_flight, fn path ->
|
||||||
|
Task.async(fn ->
|
||||||
|
process_single_image(path, project_id, post_id, language, translate_targets, parent)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
known_refs = MapSet.new(tasks, & &1.ref)
|
||||||
|
|
||||||
|
drain_tasks(
|
||||||
|
remaining, tasks, known_refs, project_id, post_id, language, translate_targets, parent
|
||||||
|
)
|
||||||
|
|
||||||
|
send(parent, {:add_images_complete, length(paths)})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp drain_tasks(
|
||||||
|
[], tasks, _known_refs, _project_id, _post_id, _language, _translate_targets, _parent
|
||||||
|
) do
|
||||||
|
Enum.each(tasks, fn task -> Task.await(task, :infinity) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp drain_tasks(
|
||||||
|
[next_path | rest],
|
||||||
|
tasks,
|
||||||
|
known_refs,
|
||||||
|
project_id,
|
||||||
|
post_id,
|
||||||
|
language,
|
||||||
|
translate_targets,
|
||||||
|
parent
|
||||||
|
) do
|
||||||
|
receive do
|
||||||
|
{ref, _result} when is_reference(ref) ->
|
||||||
|
if MapSet.member?(known_refs, ref) do
|
||||||
|
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
|
||||||
|
|
||||||
|
new_task =
|
||||||
|
Task.async(fn ->
|
||||||
|
process_single_image(
|
||||||
|
next_path, project_id, post_id, language, translate_targets, parent
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
drain_tasks(
|
||||||
|
rest,
|
||||||
|
[new_task | remaining_tasks],
|
||||||
|
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
|
||||||
|
project_id,
|
||||||
|
post_id,
|
||||||
|
language,
|
||||||
|
translate_targets,
|
||||||
|
parent
|
||||||
|
)
|
||||||
|
else
|
||||||
|
drain_tasks(
|
||||||
|
[next_path | rest], tasks, known_refs,
|
||||||
|
project_id, post_id, language, translate_targets, parent
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:DOWN, ref, :process, _pid, _reason} when is_reference(ref) ->
|
||||||
|
if MapSet.member?(known_refs, ref) do
|
||||||
|
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
|
||||||
|
|
||||||
|
new_task =
|
||||||
|
Task.async(fn ->
|
||||||
|
process_single_image(
|
||||||
|
next_path, project_id, post_id, language, translate_targets, parent
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
drain_tasks(
|
||||||
|
rest,
|
||||||
|
[new_task | remaining_tasks],
|
||||||
|
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
|
||||||
|
project_id,
|
||||||
|
post_id,
|
||||||
|
language,
|
||||||
|
translate_targets,
|
||||||
|
parent
|
||||||
|
)
|
||||||
|
else
|
||||||
|
drain_tasks(
|
||||||
|
[next_path | rest], tasks, known_refs,
|
||||||
|
project_id, post_id, language, translate_targets, parent
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pop_task_by_ref(tasks, ref) do
|
||||||
|
Enum.reduce(tasks, {nil, []}, fn
|
||||||
|
%{ref: ^ref} = task, {nil, rest} -> {task, rest}
|
||||||
|
task, {found, rest} -> {found, [task | rest]}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp process_single_image(
|
||||||
|
path, project_id, post_id, language, translate_targets, parent
|
||||||
|
) do
|
||||||
|
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
|
||||||
|
true <- String.starts_with?(media.mime_type || "", "image/"),
|
||||||
|
{:ok, result} <- AI.analyze_image(media.id, language: language),
|
||||||
|
{:ok, _updated} <- Media.update_media(media.id, %{
|
||||||
|
title: result.title,
|
||||||
|
alt: result.alt,
|
||||||
|
caption: result.caption
|
||||||
|
}),
|
||||||
|
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
||||||
|
translate_media_translations(media.id, translate_targets)
|
||||||
|
title = result.title || media.original_name
|
||||||
|
send(parent, {:add_image_processed, title})
|
||||||
|
else
|
||||||
|
false ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Image pipeline error for #{path}: #{inspect(reason)}")
|
||||||
|
send(parent, {:add_image_error, path, reason})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_media_translations(_media_id, []), do: :ok
|
||||||
|
|
||||||
|
defp translate_media_translations(media_id, [target | rest]) do
|
||||||
|
case AI.translate_media(media_id, target) do
|
||||||
|
{:ok, translation} ->
|
||||||
|
Media.upsert_media_translation(media_id, target, %{
|
||||||
|
title: translation.title,
|
||||||
|
alt: translation.alt,
|
||||||
|
caption: translation.caption
|
||||||
|
})
|
||||||
|
|
||||||
|
translate_media_translations(media_id, rest)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"Media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
translate_media_translations(media_id, rest)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -266,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
@spec default_author(term()) :: term()
|
@spec default_author(term()) :: term()
|
||||||
def default_author(project_id) do
|
def default_author(project_id) do
|
||||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
Map.get(metadata, :default_author)
|
metadata.default_author
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec suggested_definition_name(term()) :: term()
|
@spec suggested_definition_name(term()) :: term()
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
categories: Enum.uniq(Map.get(metadata, :categories, []) || []),
|
categories: Enum.uniq(metadata.categories || []),
|
||||||
tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq()
|
tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq()
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ defmodule BDS.Desktop.ShellLive.Notify do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec schedule_auto_save(atom(), term()) :: :ok
|
||||||
|
def schedule_auto_save(type, id) do
|
||||||
|
send(self(), {:schedule_auto_save, type, id})
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec cancel_auto_save(atom(), term()) :: :ok
|
||||||
|
def cancel_auto_save(type, id) do
|
||||||
|
send(self(), {:cancel_auto_save, type, id})
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
@spec parent(term()) :: :ok
|
@spec parent(term()) :: :ok
|
||||||
def parent(message) do
|
def parent(message) do
|
||||||
send(self(), message)
|
send(self(), message)
|
||||||
|
|||||||
@@ -185,6 +185,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
Notify.dirty(:post, post_id, dirty?)
|
Notify.dirty(:post, post_id, dirty?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if dirty? do
|
||||||
|
Notify.schedule_auto_save(:post, post_id)
|
||||||
|
end
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -204,6 +208,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
{:noreply, do_delete(socket)}
|
{:noreply, do_delete(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("archive_post_editor", _params, socket) do
|
||||||
|
{:noreply, do_archive(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("unarchive_post_editor", _params, socket) do
|
||||||
|
{:noreply, do_unarchive(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("set_post_editor_mode", %{"mode" => mode}, socket) do
|
def handle_event("set_post_editor_mode", %{"mode" => mode}, socket) do
|
||||||
normalized_mode = normalize_mode(mode)
|
normalized_mode = normalize_mode(mode)
|
||||||
|
|
||||||
@@ -370,6 +382,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
editing_canonical_language?(translations, active_language, canonical_language),
|
editing_canonical_language?(translations, active_language, canonical_language),
|
||||||
can_publish?: post.status == :draft,
|
can_publish?: post.status == :draft,
|
||||||
can_delete?: post.status == :published,
|
can_delete?: post.status == :published,
|
||||||
|
can_archive?: post.status in [:draft, :published],
|
||||||
|
can_unarchive?: post.status == :archived,
|
||||||
has_published_version?: has_published_version?(post),
|
has_published_version?: has_published_version?(post),
|
||||||
discard_label: discard_label(post),
|
discard_label: discard_label(post),
|
||||||
discard_title: discard_title(post),
|
discard_title: discard_title(post),
|
||||||
@@ -461,6 +475,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
Atom.to_string(record_status(record)))
|
Atom.to_string(record_status(record)))
|
||||||
|
|
||||||
Notify.dirty(:post, post.id, false)
|
Notify.dirty(:post, post.id, false)
|
||||||
|
Notify.cancel_auto_save(:post, post.id)
|
||||||
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post saved"))
|
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post saved"))
|
||||||
socket
|
socket
|
||||||
|
|
||||||
@@ -559,6 +574,72 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp do_archive(socket) do
|
||||||
|
case socket.assigns.post do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
%Post{} = post ->
|
||||||
|
case Posts.archive_post(post.id) do
|
||||||
|
{:ok, archived_post} ->
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:post, archived_post)
|
||||||
|
|> assign(:drafts, %{})
|
||||||
|
|> assign(:dirty?, false)
|
||||||
|
|> assign(:quick_actions_open?, false)
|
||||||
|
|> build_data()
|
||||||
|
|
||||||
|
Notify.tab_meta(
|
||||||
|
:post,
|
||||||
|
post.id,
|
||||||
|
archived_post.title || archived_post.slug || archived_post.id,
|
||||||
|
"archived"
|
||||||
|
)
|
||||||
|
|
||||||
|
Notify.dirty(:post, post.id, false)
|
||||||
|
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post archived"))
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|
||||||
|
|> build_data()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_unarchive(socket) do
|
||||||
|
case socket.assigns.post do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
%Post{} = post ->
|
||||||
|
case Posts.unarchive_post(post.id) do
|
||||||
|
{:ok, unarchived_post} ->
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:post, unarchived_post)
|
||||||
|
|> assign(:drafts, %{})
|
||||||
|
|> assign(:dirty?, false)
|
||||||
|
|> assign(:quick_actions_open?, false)
|
||||||
|
|> build_data()
|
||||||
|
|
||||||
|
Notify.tab_meta(
|
||||||
|
:post,
|
||||||
|
post.id,
|
||||||
|
unarchived_post.title || unarchived_post.slug || unarchived_post.id,
|
||||||
|
"draft"
|
||||||
|
)
|
||||||
|
|
||||||
|
Notify.dirty(:post, post.id, false)
|
||||||
|
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post unarchived"))
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|
||||||
|
|> build_data()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp do_detect_language(socket) do
|
defp do_detect_language(socket) do
|
||||||
if Map.get(socket.assigns, :offline_mode, true) do
|
if Map.get(socket.assigns, :offline_mode, true) do
|
||||||
notify_output(
|
notify_output(
|
||||||
|
|||||||
@@ -168,11 +168,19 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|
|||||||
|
|
||||||
@spec gallery_count(term()) :: term()
|
@spec gallery_count(term()) :: term()
|
||||||
def gallery_count(form) do
|
def gallery_count(form) do
|
||||||
form
|
content = form |> Map.get("content", "") |> to_string()
|
||||||
|> Map.get("content", "")
|
|
||||||
|> to_string()
|
image_count =
|
||||||
|
content
|
||||||
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|
||||||
|> length()
|
|> length()
|
||||||
|
|
||||||
|
gallery_macro_count =
|
||||||
|
content
|
||||||
|
|> then(&Regex.scan(~r/\[\[gallery\]\]/i, &1))
|
||||||
|
|> length()
|
||||||
|
|
||||||
|
max(image_count, gallery_macro_count)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec preview_url(term(), term(), term(), term()) :: term()
|
@spec preview_url(term(), term(), term(), term()) :: term()
|
||||||
|
|||||||
@@ -61,6 +61,42 @@
|
|||||||
<small><%= dgettext("ui", "Select a target language for this post") %></small>
|
<small><%= dgettext("ui", "Select a target language for this post") %></small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<%= if @post_editor.can_archive? or @post_editor.can_unarchive? do %>
|
||||||
|
<div class="quick-actions-divider"></div>
|
||||||
|
|
||||||
|
<%= if @post_editor.can_archive? do %>
|
||||||
|
<button
|
||||||
|
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||||
|
data-testid="post-archive-button"
|
||||||
|
type="button"
|
||||||
|
phx-click="archive_post_editor"
|
||||||
|
phx-target={@myself}
|
||||||
|
>
|
||||||
|
<span class="quick-action-icon">📦</span>
|
||||||
|
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||||
|
<strong><%= dgettext("ui", "Archive") %></strong>
|
||||||
|
<small><%= dgettext("ui", "Move this post to the archive") %></small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @post_editor.can_unarchive? do %>
|
||||||
|
<button
|
||||||
|
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||||
|
data-testid="post-unarchive-button"
|
||||||
|
type="button"
|
||||||
|
phx-click="unarchive_post_editor"
|
||||||
|
phx-target={@myself}
|
||||||
|
>
|
||||||
|
<span class="quick-action-icon">📤</span>
|
||||||
|
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||||
|
<strong><%= dgettext("ui", "Unarchive") %></strong>
|
||||||
|
<small><%= dgettext("ui", "Restore this post to draft") %></small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,6 +398,14 @@
|
|||||||
>
|
>
|
||||||
<%= dgettext("ui", "Insert Media") %>
|
<%= dgettext("ui", "Insert Media") %>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="add-gallery-images-button"
|
||||||
|
type="button"
|
||||||
|
phx-click="add_gallery_images"
|
||||||
|
phx-value-post-id={@post_editor.id}
|
||||||
|
>
|
||||||
|
<%= dgettext("ui", "Add Gallery Images") %>
|
||||||
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @post_editor.gallery_count > 0 do %>
|
<%= if @post_editor.gallery_count > 0 do %>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
|
|||||||
|
|
||||||
@spec category_rows(term()) :: term()
|
@spec category_rows(term()) :: term()
|
||||||
def category_rows(metadata) do
|
def category_rows(metadata) do
|
||||||
categories = Map.get(metadata, :categories, [])
|
categories = metadata.categories
|
||||||
settings = Map.get(metadata, :category_settings, %{})
|
settings = metadata.category_settings
|
||||||
|
|
||||||
Enum.map(categories, fn category ->
|
Enum.map(categories, fn category ->
|
||||||
category_settings = Map.get(settings, category, %{})
|
category_settings = Map.get(settings, category, %{})
|
||||||
@@ -167,7 +167,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp category_names(metadata), do: Map.get(metadata, :categories, [])
|
defp category_names(metadata), do: metadata.categories
|
||||||
|
|
||||||
defp ensure_default_categories(project_id) do
|
defp ensure_default_categories(project_id) do
|
||||||
Enum.reduce_while(Map.keys(@default_category_settings), :ok, fn category, _acc ->
|
Enum.reduce_while(Map.keys(@default_category_settings), :ok, fn category, _acc ->
|
||||||
|
|||||||
@@ -16,17 +16,18 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
|||||||
@spec project_form(term()) :: term()
|
@spec project_form(term()) :: term()
|
||||||
def project_form(metadata) do
|
def project_form(metadata) do
|
||||||
%{
|
%{
|
||||||
"name" => Map.get(metadata, :name, ""),
|
"name" => metadata.name || "",
|
||||||
"description" => Map.get(metadata, :description, ""),
|
"description" => metadata.description || "",
|
||||||
"public_url" => Map.get(metadata, :public_url, ""),
|
"public_url" => metadata.public_url || "",
|
||||||
"main_language" => Map.get(metadata, :main_language) || "en",
|
"main_language" => metadata.main_language || "en",
|
||||||
"default_author" => Map.get(metadata, :default_author, ""),
|
"default_author" => metadata.default_author || "",
|
||||||
"max_posts_per_page" => Integer.to_string(Map.get(metadata, :max_posts_per_page, 50)),
|
"max_posts_per_page" => Integer.to_string(metadata.max_posts_per_page),
|
||||||
|
"image_import_concurrency" => Integer.to_string(metadata.image_import_concurrency),
|
||||||
"blogmark_category" =>
|
"blogmark_category" =>
|
||||||
Map.get(metadata, :blogmark_category) ||
|
metadata.blogmark_category ||
|
||||||
List.first(Map.get(metadata, :categories, [])) || "article",
|
List.first(metadata.categories) || "article",
|
||||||
"blog_languages" => Map.get(metadata, :blog_languages, []),
|
"blog_languages" => metadata.blog_languages,
|
||||||
"semantic_similarity_enabled" => Map.get(metadata, :semantic_similarity_enabled, false)
|
"semantic_similarity_enabled" => metadata.semantic_similarity_enabled
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
|||||||
main_language: blank_to_nil(Map.get(draft, "main_language")),
|
main_language: blank_to_nil(Map.get(draft, "main_language")),
|
||||||
default_author: blank_to_nil(Map.get(draft, "default_author")),
|
default_author: blank_to_nil(Map.get(draft, "default_author")),
|
||||||
max_posts_per_page: parse_integer(Map.get(draft, "max_posts_per_page"), 50),
|
max_posts_per_page: parse_integer(Map.get(draft, "max_posts_per_page"), 50),
|
||||||
|
image_import_concurrency: parse_integer(Map.get(draft, "image_import_concurrency"), 4),
|
||||||
blogmark_category: blank_to_nil(Map.get(draft, "blogmark_category")),
|
blogmark_category: blank_to_nil(Map.get(draft, "blogmark_category")),
|
||||||
blog_languages: Map.get(draft, "blog_languages", []),
|
blog_languages: Map.get(draft, "blog_languages", []),
|
||||||
semantic_similarity_enabled: truthy?(Map.get(draft, "semantic_similarity_enabled"))
|
semantic_similarity_enabled: truthy?(Map.get(draft, "semantic_similarity_enabled"))
|
||||||
@@ -85,6 +87,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
|||||||
"main_language" => Map.get(params, "main_language", "en"),
|
"main_language" => Map.get(params, "main_language", "en"),
|
||||||
"default_author" => Map.get(params, "default_author", ""),
|
"default_author" => Map.get(params, "default_author", ""),
|
||||||
"max_posts_per_page" => Map.get(params, "max_posts_per_page", "50"),
|
"max_posts_per_page" => Map.get(params, "max_posts_per_page", "50"),
|
||||||
|
"image_import_concurrency" => Map.get(params, "image_import_concurrency", "4"),
|
||||||
"blogmark_category" => Map.get(params, "blogmark_category", "article"),
|
"blogmark_category" => Map.get(params, "blogmark_category", "article"),
|
||||||
"blog_languages" => List.wrap(Map.get(params, "blog_languages", [])),
|
"blog_languages" => List.wrap(Map.get(params, "blog_languages", [])),
|
||||||
"semantic_similarity_enabled" => truthy?(Map.get(params, "semantic_similarity_enabled"))
|
"semantic_similarity_enabled" => truthy?(Map.get(params, "semantic_similarity_enabled"))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
|
|||||||
|
|
||||||
@spec publishing_form(term()) :: term()
|
@spec publishing_form(term()) :: term()
|
||||||
def publishing_form(metadata) do
|
def publishing_form(metadata) do
|
||||||
prefs = Map.get(metadata, :publishing_preferences, %{})
|
prefs = metadata.publishing_preferences
|
||||||
|
|
||||||
%{
|
%{
|
||||||
"ssh_host" => Map.get(prefs, "ssh_host", ""),
|
"ssh_host" => Map.get(prefs, "ssh_host", ""),
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
|
|||||||
def current_theme(assigns) do
|
def current_theme(assigns) do
|
||||||
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
|
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
|
||||||
{:ok, metadata} ->
|
{:ok, metadata} ->
|
||||||
case Map.get(metadata, :pico_theme) do
|
case metadata.pico_theme do
|
||||||
nil -> "default"
|
nil -> "default"
|
||||||
"" -> "default"
|
"" -> "default"
|
||||||
theme -> theme
|
theme -> theme
|
||||||
|
|||||||
@@ -82,6 +82,10 @@
|
|||||||
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Max Posts Per Page") %></label></div>
|
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Max Posts Per Page") %></label></div>
|
||||||
<div class="setting-control"><input class="ui-input" type="number" min="1" max="500" name="settings_project[max_posts_per_page]" value={@settings_editor.project["max_posts_per_page"]} /></div>
|
<div class="setting-control"><input class="ui-input" type="number" min="1" max="500" name="settings_project[max_posts_per_page]" value={@settings_editor.project["max_posts_per_page"]} /></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Image Import Concurrency") %></label></div>
|
||||||
|
<div class="setting-control"><input class="ui-input" type="number" min="1" max="8" name="settings_project[image_import_concurrency]" value={@settings_editor.project["image_import_concurrency"]} /></div>
|
||||||
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Category") %></label></div>
|
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Category") %></label></div>
|
||||||
<div class="setting-control">
|
<div class="setting-control">
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
|
|||||||
"media_grid" -> render_media_sidebar(assigns)
|
"media_grid" -> render_media_sidebar(assigns)
|
||||||
"entity_list" -> render_entity_sidebar(assigns)
|
"entity_list" -> render_entity_sidebar(assigns)
|
||||||
"nav_list" -> render_nav_sidebar(assigns)
|
"nav_list" -> render_nav_sidebar(assigns)
|
||||||
|
"git" -> render_git_sidebar(assigns)
|
||||||
_other -> render_default_sidebar(assigns)
|
_other -> render_default_sidebar(assigns)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -483,6 +484,141 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp render_git_sidebar(assigns) do
|
||||||
|
assigns = assign(assigns, :git_state, Map.get(assigns.sidebar_data, :git_state, "not_a_repo"))
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="git-sidebar">
|
||||||
|
<%= if @git_state == "active" do %>
|
||||||
|
<%= render_git_active(assigns) %>
|
||||||
|
<% else %>
|
||||||
|
<%= render_git_not_a_repo(assigns) %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_git_not_a_repo(assigns) do
|
||||||
|
~H"""
|
||||||
|
<section class="git-section git-not-a-repo">
|
||||||
|
<p class="git-empty-hint"><%= dgettext("ui", "This project is not a Git repository yet.") %></p>
|
||||||
|
<form class="git-init-form flex flex-col gap-2" data-testid="git-init-form" phx-submit="git_initialize">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="git[remote_url]"
|
||||||
|
placeholder={dgettext("ui", "Remote URL (optional)")}
|
||||||
|
value={Map.get(@sidebar_data, :remote_url) || ""}
|
||||||
|
/>
|
||||||
|
<button class="git-action-button" data-testid="git-initialize" type="submit">
|
||||||
|
<%= dgettext("ui", "Initialize Git") %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_git_active(assigns) do
|
||||||
|
~H"""
|
||||||
|
<header class="git-header">
|
||||||
|
<div class="git-branch-row flex items-center gap-2">
|
||||||
|
<span class="git-branch-icon">⎇</span>
|
||||||
|
<span class="git-branch" data-testid="git-branch"><%= @sidebar_data.branch %></span>
|
||||||
|
<%= if @sidebar_data.upstream do %>
|
||||||
|
<span class="git-upstream" data-testid="git-upstream"><%= @sidebar_data.upstream %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="git-tracking flex items-center gap-3">
|
||||||
|
<span class="git-ahead" data-testid="git-ahead" title={dgettext("ui", "Ahead")}>↑ <%= @sidebar_data.ahead %></span>
|
||||||
|
<span class="git-behind" data-testid="git-behind" title={dgettext("ui", "Behind")}>↓ <%= @sidebar_data.behind %></span>
|
||||||
|
</div>
|
||||||
|
<div class="git-sync-legend flex items-center gap-3">
|
||||||
|
<span class="git-legend-item"><span class="git-sync-dot git-sync-synced"></span><%= dgettext("ui", "Synced") %></span>
|
||||||
|
<span class="git-legend-item"><span class="git-sync-dot git-sync-local_only"></span><%= dgettext("ui", "Local only") %></span>
|
||||||
|
<span class="git-legend-item"><span class="git-sync-dot git-sync-remote_only"></span><%= dgettext("ui", "Remote only") %></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="git-actions flex items-center gap-2">
|
||||||
|
<button class="git-action-button" data-testid="git-action-fetch" type="button" phx-click="git_fetch" title={dgettext("ui", "Fetch")}><%= dgettext("ui", "Fetch") %></button>
|
||||||
|
<button class="git-action-button" data-testid="git-action-pull" type="button" phx-click="git_pull" title={dgettext("ui", "Pull")}><%= dgettext("ui", "Pull") %></button>
|
||||||
|
<button class="git-action-button" data-testid="git-action-push" type="button" phx-click="git_push" title={dgettext("ui", "Push")}><%= dgettext("ui", "Push") %></button>
|
||||||
|
<button class="git-action-button" data-testid="git-action-prune-lfs" type="button" phx-click="git_prune_lfs" title={dgettext("ui", "Prune LFS")}><%= dgettext("ui", "Prune LFS") %></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="git-section git-changes">
|
||||||
|
<div class="git-section-title">
|
||||||
|
<span><%= dgettext("ui", "Changes") %></span>
|
||||||
|
<span class="git-section-count"><%= length(@sidebar_data.status_files) %></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="git-commit-form flex flex-col gap-2" data-testid="git-commit-form" phx-submit="git_commit">
|
||||||
|
<input type="text" name="git[message]" placeholder={dgettext("ui", "Commit message")} />
|
||||||
|
<button class="git-action-button" data-testid="git-commit" type="submit"><%= dgettext("ui", "Commit") %></button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<%= if Enum.any?(@sidebar_data.status_files) do %>
|
||||||
|
<div class="git-status-list flex flex-col">
|
||||||
|
<%= for file <- @sidebar_data.status_files do %>
|
||||||
|
<button
|
||||||
|
class="git-status-file flex items-center justify-between gap-2"
|
||||||
|
data-testid="git-status-file"
|
||||||
|
data-route="git_diff"
|
||||||
|
type="button"
|
||||||
|
title={"#{file.label}: #{file.path}"}
|
||||||
|
phx-click="open_sidebar_item"
|
||||||
|
phx-value-route="git_diff"
|
||||||
|
phx-value-id={"git-diff:" <> file.path}
|
||||||
|
phx-value-title={file.path}
|
||||||
|
phx-value-subtitle={file.label}
|
||||||
|
>
|
||||||
|
<span class="git-status-path"><%= file.path %></span>
|
||||||
|
<span class={"git-status-badge git-status-#{file.status}"}><%= file.code %></span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="git-empty-hint"><%= dgettext("ui", "No changes") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="git-section git-history">
|
||||||
|
<div class="git-section-title">
|
||||||
|
<span><%= dgettext("ui", "History") %></span>
|
||||||
|
</div>
|
||||||
|
<%= if Enum.any?(@sidebar_data.history_entries) do %>
|
||||||
|
<div class="git-history-list flex flex-col">
|
||||||
|
<%= for entry <- @sidebar_data.history_entries do %>
|
||||||
|
<button
|
||||||
|
class="git-history-entry flex flex-col"
|
||||||
|
data-testid="git-history-entry"
|
||||||
|
data-route="git_diff"
|
||||||
|
type="button"
|
||||||
|
phx-click="open_sidebar_item"
|
||||||
|
phx-value-route="git_diff"
|
||||||
|
phx-value-id={"git-diff:commit:" <> entry.short_hash}
|
||||||
|
phx-value-title={entry.short_hash}
|
||||||
|
phx-value-subtitle={entry.subject || ""}
|
||||||
|
>
|
||||||
|
<span class="git-history-subject"><%= entry.subject %></span>
|
||||||
|
<span class="git-history-meta flex items-center gap-2">
|
||||||
|
<span class={"git-sync-dot git-sync-#{entry.sync_status}"}></span>
|
||||||
|
<span class="git-history-hash"><%= entry.short_hash %></span>
|
||||||
|
<%= if entry.author do %><span class="git-history-author"><%= entry.author %></span><% end %>
|
||||||
|
<%= if entry.date do %><span class="git-history-date"><%= entry.date %></span><% end %>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<%= if @sidebar_data.has_more_history do %>
|
||||||
|
<p class="git-history-more"><%= dgettext("ui", "Older history available") %></p>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="git-empty-hint"><%= dgettext("ui", "No commits yet") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
defp render_default_sidebar(assigns) do
|
defp render_default_sidebar(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= for section <- Map.get(@sidebar_data, :sections, []) do %>
|
<%= for section <- Map.get(@sidebar_data, :sections, []) do %>
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
|||||||
|
|
||||||
@tags_sections ~w(cloud manage merge)
|
@tags_sections ~w(cloud manage merge)
|
||||||
|
|
||||||
|
@colour_presets ~w(
|
||||||
|
#ef4444 #f97316 #f59e0b #eab308 #84cc16
|
||||||
|
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
|
||||||
|
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
|
||||||
|
#ec4899 #64748b
|
||||||
|
)
|
||||||
|
|
||||||
|
@spec colour_presets() :: [String.t()]
|
||||||
|
def colour_presets, do: @colour_presets
|
||||||
|
|
||||||
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
|
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
|
||||||
@impl true
|
@impl true
|
||||||
def update(%{action: :save} = assigns, socket) do
|
def update(%{action: :save} = assigns, socket) do
|
||||||
@@ -107,6 +117,26 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
|||||||
{:noreply, assign(socket, :tags_editor, tags_editor)}
|
{:noreply, assign(socket, :tags_editor, tags_editor)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("pick_new_tag_color", %{"color" => color}, socket) do
|
||||||
|
tags_editor =
|
||||||
|
Map.put(socket.assigns.tags_editor, :new_tag, %{
|
||||||
|
socket.assigns.tags_editor.new_tag
|
||||||
|
| "color" => color
|
||||||
|
})
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :tags_editor, tags_editor)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("pick_edit_tag_color", %{"color" => color}, socket) do
|
||||||
|
tags_editor =
|
||||||
|
Map.put(socket.assigns.tags_editor, :edit_draft, %{
|
||||||
|
socket.assigns.tags_editor.edit_draft
|
||||||
|
| "color" => color
|
||||||
|
})
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :tags_editor, tags_editor)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("save_tag_editor", _params, socket) do
|
def handle_event("save_tag_editor", _params, socket) do
|
||||||
{:noreply, do_save(socket)}
|
{:noreply, do_save(socket)}
|
||||||
end
|
end
|
||||||
@@ -241,6 +271,55 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attr :color, :string, default: nil
|
||||||
|
attr :presets, :list, required: true
|
||||||
|
attr :pick_event, :string, required: true
|
||||||
|
attr :target, :any, required: true
|
||||||
|
|
||||||
|
defp colour_picker(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
class="colour-picker-wrap"
|
||||||
|
id={"cp-#{@pick_event}"}
|
||||||
|
phx-hook="ColourPicker"
|
||||||
|
data-pick-event={@pick_event}
|
||||||
|
data-target={if @target, do: @target.cid}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="colour-picker-trigger"
|
||||||
|
style={"background-color: #{if @color in [nil, ""], do: "#3b82f6", else: @color}"}
|
||||||
|
phx-click={Phoenix.LiveView.JS.toggle(to: "#cp-#{@pick_event} .colour-picker-popover")}
|
||||||
|
/>
|
||||||
|
<div class="colour-picker-popover hidden">
|
||||||
|
<div class="colour-picker-grid">
|
||||||
|
<%= for preset <- @presets do %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={"colour-picker-swatch#{if normalize_hex(@color) == normalize_hex(preset), do: " selected", else: ""}"}
|
||||||
|
style={"background-color: #{preset}"}
|
||||||
|
phx-click={Phoenix.LiveView.JS.push(@pick_event, value: %{color: preset}, target: if(@target, do: @target.cid)) |> Phoenix.LiveView.JS.add_class("hidden", to: "#cp-#{@pick_event} .colour-picker-popover")}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="colour-picker-custom">
|
||||||
|
<label>#</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxlength="7"
|
||||||
|
placeholder="RRGGBB"
|
||||||
|
value={if @color in [nil, ""], do: "", else: @color}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_hex(nil), do: nil
|
||||||
|
defp normalize_hex(""), do: nil
|
||||||
|
defp normalize_hex(hex), do: String.downcase(hex)
|
||||||
|
|
||||||
defp load_data(socket) do
|
defp load_data(socket) do
|
||||||
project_id = socket.assigns.project_id
|
project_id = socket.assigns.project_id
|
||||||
|
|
||||||
@@ -280,7 +359,8 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
|||||||
merge_target:
|
merge_target:
|
||||||
Map.get(socket.assigns, :tags_editor, %{})
|
Map.get(socket.assigns, :tags_editor, %{})
|
||||||
|> Map.get(:merge_target, List.first(selected) || ""),
|
|> Map.get(:merge_target, List.first(selected) || ""),
|
||||||
selected_section: selected_section
|
selected_section: selected_section,
|
||||||
|
colour_presets: @colour_presets
|
||||||
}
|
}
|
||||||
|
|
||||||
assign(socket, :tags_editor, data)
|
assign(socket, :tags_editor, data)
|
||||||
|
|||||||
@@ -38,7 +38,13 @@
|
|||||||
<form class="tag-create-form" phx-change="change_new_tag_editor" phx-target={@myself}>
|
<form class="tag-create-form" phx-change="change_new_tag_editor" phx-target={@myself}>
|
||||||
<div class="tag-form-row flex flex-wrap items-center gap-3">
|
<div class="tag-form-row flex flex-wrap items-center gap-3">
|
||||||
<input class="ui-input" type="text" name="new_tag[name]" value={@tags_editor.new_tag["name"]} placeholder={dgettext("ui", "Tag name")} />
|
<input class="ui-input" type="text" name="new_tag[name]" value={@tags_editor.new_tag["name"]} placeholder={dgettext("ui", "Tag name")} />
|
||||||
<input type="color" name="new_tag[color]" value={if(@tags_editor.new_tag["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.new_tag["color"])} />
|
<input type="hidden" name="new_tag[color]" value={@tags_editor.new_tag["color"] || ""} />
|
||||||
|
<.colour_picker
|
||||||
|
color={@tags_editor.new_tag["color"]}
|
||||||
|
presets={@tags_editor.colour_presets}
|
||||||
|
pick_event="pick_new_tag_color"
|
||||||
|
target={@myself}
|
||||||
|
/>
|
||||||
<button class="primary ui-button ui-button-primary" type="button" phx-click="create_tag_editor" phx-target={@myself}><%= dgettext("ui", "Create") %></button>
|
<button class="primary ui-button ui-button-primary" type="button" phx-click="create_tag_editor" phx-target={@myself}><%= dgettext("ui", "Create") %></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -47,7 +53,13 @@
|
|||||||
<form class="tag-edit-form" phx-change="change_edit_tag_editor" phx-target={@myself}>
|
<form class="tag-edit-form" phx-change="change_edit_tag_editor" phx-target={@myself}>
|
||||||
<div class="tag-form-row flex flex-wrap items-center gap-3">
|
<div class="tag-form-row flex flex-wrap items-center gap-3">
|
||||||
<input class="ui-input" type="text" name="edit_tag[name]" value={@tags_editor.edit_draft["name"]} />
|
<input class="ui-input" type="text" name="edit_tag[name]" value={@tags_editor.edit_draft["name"]} />
|
||||||
<input type="color" name="edit_tag[color]" value={if(@tags_editor.edit_draft["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.edit_draft["color"])} />
|
<input type="hidden" name="edit_tag[color]" value={@tags_editor.edit_draft["color"] || ""} />
|
||||||
|
<.colour_picker
|
||||||
|
color={@tags_editor.edit_draft["color"]}
|
||||||
|
presets={@tags_editor.colour_presets}
|
||||||
|
pick_event="pick_edit_tag_color"
|
||||||
|
target={@myself}
|
||||||
|
/>
|
||||||
<select class="ui-input" name="edit_tag[post_template_slug]">
|
<select class="ui-input" name="edit_tag[post_template_slug]">
|
||||||
<option value=""><%= dgettext("ui", "No Template") %></option>
|
<option value=""><%= dgettext("ui", "No Template") %></option>
|
||||||
<%= for template <- @tags_editor.templates do %>
|
<%= for template <- @tags_editor.templates do %>
|
||||||
|
|||||||
@@ -61,9 +61,26 @@ defmodule BDS.Desktop.Shutdown do
|
|||||||
|
|
||||||
def command_menu_selected(_event, _command_event), do: :ok
|
def command_menu_selected(_event, _command_event), do: :ok
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Terminate the OS process directly with SIGKILL.
|
||||||
|
|
||||||
|
`Desktop.Window.quit/0` routes through `System.halt/1`, which calls the libc
|
||||||
|
`exit()` and runs the wxWidgets C++ static destructors on the way out. On
|
||||||
|
macOS that races the still-running wx event loop on the main thread and
|
||||||
|
segfaults (`wxMenu::~wxMenu` vs `wxAppBase::ProcessIdle`). A SIGKILL is a
|
||||||
|
kernel-level termination that skips those destructors entirely, so the app
|
||||||
|
exits cleanly without producing a crash report.
|
||||||
|
"""
|
||||||
|
@spec quit() :: :ok
|
||||||
|
def quit do
|
||||||
|
kill_heart()
|
||||||
|
kill_beam()
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
defp start_shutdown_task do
|
defp start_shutdown_task do
|
||||||
Task.start(fn ->
|
Task.start(fn ->
|
||||||
MainWindow.persist_now()
|
persist_safely()
|
||||||
maybe_hide_window()
|
maybe_hide_window()
|
||||||
Process.sleep(50)
|
Process.sleep(50)
|
||||||
quit_module().quit()
|
quit_module().quit()
|
||||||
@@ -72,6 +89,57 @@ defmodule BDS.Desktop.Shutdown do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp persist_safely do
|
||||||
|
MainWindow.persist_now()
|
||||||
|
:ok
|
||||||
|
rescue
|
||||||
|
_error -> :ok
|
||||||
|
catch
|
||||||
|
:exit, _reason -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# heart, when present, would relaunch the app after we kill the BEAM, so it
|
||||||
|
# has to be terminated first. When the app is started without heart (e.g. via
|
||||||
|
# `mix`) there is nothing to do here.
|
||||||
|
defp kill_heart do
|
||||||
|
with heart when is_pid(heart) <- Process.whereis(:heart),
|
||||||
|
{:links, links} <- Process.info(heart, :links),
|
||||||
|
port when is_port(port) <- Enum.find(links, &is_port/1),
|
||||||
|
{:os_pid, heart_pid} <- Port.info(port, :os_pid) do
|
||||||
|
os_kill(heart_pid)
|
||||||
|
else
|
||||||
|
_ -> :ok
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
_error -> :ok
|
||||||
|
catch
|
||||||
|
:exit, _reason -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp kill_beam do
|
||||||
|
os_kill(:os.getpid())
|
||||||
|
end
|
||||||
|
|
||||||
|
defp os_kill(os_pid) do
|
||||||
|
os_kill_fun().(os_pid)
|
||||||
|
:ok
|
||||||
|
rescue
|
||||||
|
_error -> :ok
|
||||||
|
catch
|
||||||
|
:exit, _reason -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp os_kill_fun do
|
||||||
|
Application.get_env(:bds, :desktop_os_kill_fun, &__MODULE__.hard_kill/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec hard_kill(charlist() | integer() | String.t()) :: :ok
|
||||||
|
def hard_kill(os_pid) do
|
||||||
|
System.cmd("kill", ["-9", to_string(os_pid)], stderr_to_stdout: true)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
defp maybe_hide_window do
|
defp maybe_hide_window do
|
||||||
module = window_module()
|
module = window_module()
|
||||||
|
|
||||||
@@ -86,8 +154,10 @@ defmodule BDS.Desktop.Shutdown do
|
|||||||
:exit, _reason -> :ok
|
:exit, _reason -> :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp quit_module do
|
@doc false
|
||||||
Application.get_env(:bds, :desktop_window_quit_module, Window)
|
@spec quit_module() :: module()
|
||||||
|
def quit_module do
|
||||||
|
Application.get_env(:bds, :desktop_window_quit_module, __MODULE__)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp window_module do
|
defp window_module do
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ defmodule BDS.Desktop.UILocale do
|
|||||||
process dictionary directly. Use `with_locale/2` around any render or
|
process dictionary directly. Use `with_locale/2` around any render or
|
||||||
component that needs a locale binding; use `current/0` to read it.
|
component that needs a locale binding; use `current/0` to read it.
|
||||||
|
|
||||||
|
## Invariant
|
||||||
|
|
||||||
|
Every code path that evaluates HEEx templates containing `translated/1,2`
|
||||||
|
calls **must** call `UILocale.put/1` before template evaluation:
|
||||||
|
|
||||||
|
* `ShellLive.render/1` — sets locale at the top of every LiveView render.
|
||||||
|
* `SidebarComponents.sidebar_content/1` — sets locale before the function
|
||||||
|
component's HEEx (runs in the same process, may be called outside
|
||||||
|
the parent render cycle via `send_update`).
|
||||||
|
* `MenuBar.mount/1` and `MenuBar.handle_info({:set_ui_locale, _})` — set
|
||||||
|
locale in the separate menu-bar process which has its own render cycle.
|
||||||
|
|
||||||
|
Violating this invariant causes `current/0` to return a stale or `nil`
|
||||||
|
locale, producing untranslated UI text.
|
||||||
|
|
||||||
Direct use of `Process.put(:bds_ui_locale, _)` or
|
Direct use of `Process.put(:bds_ui_locale, _)` or
|
||||||
`Process.get(:bds_ui_locale)` is forbidden outside this module.
|
`Process.get(:bds_ui_locale)` is forbidden outside this module.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ defmodule BDS.Embeddings do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
alias BDS.Embeddings.DismissedDuplicatePair
|
alias BDS.Embeddings.DismissedDuplicatePair
|
||||||
@@ -15,6 +16,7 @@ defmodule BDS.Embeddings do
|
|||||||
|
|
||||||
@duplicate_threshold 0.92
|
@duplicate_threshold 0.92
|
||||||
@exact_match_score 0.999999
|
@exact_match_score 0.999999
|
||||||
|
@key_batch_size 199
|
||||||
|
|
||||||
def model_id, do: configured_backend().model_info().model_id
|
def model_id, do: configured_backend().model_info().model_id
|
||||||
def dimensions, do: configured_backend().model_info().dimensions
|
def dimensions, do: configured_backend().model_info().dimensions
|
||||||
@@ -73,9 +75,17 @@ defmodule BDS.Embeddings do
|
|||||||
order_by: [asc: post.created_at, asc: post.slug]
|
order_by: [asc: post.created_at, asc: post.slug]
|
||||||
)
|
)
|
||||||
|
|
||||||
Enum.each(posts, &sync_post_if_enabled(&1, refresh_index: false))
|
existing_keys = preload_keys_by_post_id(project_id, Enum.map(posts, & &1.id))
|
||||||
|
|
||||||
|
case build_key_rows(posts, existing_keys, max_label_value(), nil, false) do
|
||||||
|
{:ok, rows} ->
|
||||||
|
batch_upsert_keys(rows)
|
||||||
:ok = rebuild_snapshot(project_id)
|
:ok = rebuild_snapshot(project_id)
|
||||||
{:ok, Enum.map(posts, & &1.id)}
|
{:ok, Enum.map(posts, & &1.id)}
|
||||||
|
|
||||||
|
{:error, _reason} = error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
else
|
else
|
||||||
{:ok, []}
|
{:ok, []}
|
||||||
end
|
end
|
||||||
@@ -95,25 +105,26 @@ defmodule BDS.Embeddings do
|
|||||||
)
|
)
|
||||||
|
|
||||||
post_ids = Enum.map(posts, & &1.id)
|
post_ids = Enum.map(posts, & &1.id)
|
||||||
total_posts = length(posts)
|
|
||||||
|
|
||||||
:ok = report_rebuild_started(on_progress, total_posts, "embedding entries")
|
|
||||||
|
|
||||||
Repo.delete_all(
|
Repo.delete_all(
|
||||||
from key in Key,
|
from key in Key,
|
||||||
where: key.project_id == ^project_id and key.post_id not in ^post_ids
|
where: key.project_id == ^project_id and key.post_id not in ^post_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
posts
|
existing_keys = preload_keys_by_post_id(project_id)
|
||||||
|> Enum.with_index(1)
|
|
||||||
|> Enum.each(fn {post, index} ->
|
|
||||||
sync_post_if_enabled(post, refresh_index: false)
|
|
||||||
:ok = report_rebuild_progress(on_progress, index, total_posts, "embedding entries")
|
|
||||||
end)
|
|
||||||
|
|
||||||
|
# An explicit rebuild re-embeds every post from scratch (ReindexAll),
|
||||||
|
# ignoring the content_hash skip optimisation.
|
||||||
|
case build_key_rows(posts, existing_keys, max_label_value(), on_progress, true) do
|
||||||
|
{:ok, rows} ->
|
||||||
|
batch_upsert_keys(rows)
|
||||||
:ok = report_rebuild_phase(on_progress, 0.99, "Persisting embedding snapshot")
|
:ok = report_rebuild_phase(on_progress, 0.99, "Persisting embedding snapshot")
|
||||||
:ok = rebuild_snapshot(project_id)
|
:ok = rebuild_snapshot(project_id)
|
||||||
{:ok, post_ids}
|
{:ok, post_ids}
|
||||||
|
|
||||||
|
{:error, _reason} = error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
else
|
else
|
||||||
{:ok, []}
|
{:ok, []}
|
||||||
end
|
end
|
||||||
@@ -167,16 +178,15 @@ defmodule BDS.Embeddings do
|
|||||||
|
|
||||||
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
|
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
|
||||||
%Key{content_hash: ^content_hash} ->
|
%Key{content_hash: ^content_hash} ->
|
||||||
if Keyword.get(opts, :refresh_index, true) and
|
# Embedding is already current. The HNSW index self-heals on query
|
||||||
snapshot_content_hash(post.project_id, post.id) != content_hash do
|
# (find_similar/find_duplicates rebuild when no index is loaded), so
|
||||||
:ok = rebuild_snapshot(post.project_id)
|
# there is nothing to refresh here.
|
||||||
end
|
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
existing_key ->
|
existing_key ->
|
||||||
|
case embed_text(raw_text, post.language) do
|
||||||
|
{:ok, vector} ->
|
||||||
label = existing_key_label(existing_key) || next_label()
|
label = existing_key_label(existing_key) || next_label()
|
||||||
{:ok, vector} = embed_text(raw_text, post.language)
|
|
||||||
|
|
||||||
(existing_key || %Key{})
|
(existing_key || %Key{})
|
||||||
|> Key.changeset(%{
|
|> Key.changeset(%{
|
||||||
@@ -184,7 +194,7 @@ defmodule BDS.Embeddings do
|
|||||||
post_id: post.id,
|
post_id: post.id,
|
||||||
project_id: post.project_id,
|
project_id: post.project_id,
|
||||||
content_hash: content_hash,
|
content_hash: content_hash,
|
||||||
vector: Jason.encode!(vector)
|
vector: encode_vector(vector)
|
||||||
})
|
})
|
||||||
|> Repo.insert_or_update()
|
|> Repo.insert_or_update()
|
||||||
|
|
||||||
@@ -192,9 +202,150 @@ defmodule BDS.Embeddings do
|
|||||||
:ok = rebuild_snapshot(post.project_id)
|
:ok = rebuild_snapshot(post.project_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
# Embedding is best-effort on post save: if the model is unavailable
|
||||||
|
# (e.g. offline first-use download), leave the post unindexed rather
|
||||||
|
# than failing the save. An explicit reindex surfaces the error.
|
||||||
|
Logger.warning(
|
||||||
|
"Embedding unavailable for post #{post.id}: #{inspect(reason)}; left unindexed"
|
||||||
|
)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp preload_keys_by_post_id(project_id) do
|
||||||
|
Repo.all(from key in Key, where: key.project_id == ^project_id)
|
||||||
|
|> Map.new(&{&1.post_id, &1})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp preload_keys_by_post_id(project_id, post_ids) do
|
||||||
|
Repo.all(
|
||||||
|
from key in Key,
|
||||||
|
where: key.project_id == ^project_id and key.post_id in ^post_ids
|
||||||
|
)
|
||||||
|
|> Map.new(&{&1.post_id, &1})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp max_label_value do
|
||||||
|
Repo.one(from key in Key, select: max(key.label)) || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Builds the upsert rows for a batch of posts. Unless `force?` is set, posts
|
||||||
|
# whose content_hash is unchanged are skipped (ContentHashSkipsUnchanged); the
|
||||||
|
# rest are embedded in batches (see embed_pending/2) so model inference is not
|
||||||
|
# serialised one post at a time. Labels keep their existing value or take the
|
||||||
|
# next free integer. Returns `{:error, reason}` if the model is unavailable.
|
||||||
|
defp build_key_rows(posts, existing_keys, base_label, on_progress, force?) do
|
||||||
|
prepared =
|
||||||
|
Enum.map(posts, fn post ->
|
||||||
|
raw_text = compose_embedding_source(post.title, resolve_post_body(post))
|
||||||
|
existing = Map.get(existing_keys, post.id)
|
||||||
|
content_hash = hash_text(raw_text)
|
||||||
|
|
||||||
|
%{
|
||||||
|
post: post,
|
||||||
|
existing: existing,
|
||||||
|
raw_text: raw_text,
|
||||||
|
content_hash: content_hash,
|
||||||
|
needs_embed?: force? or is_nil(existing) or existing.content_hash != content_hash
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
pending = Enum.filter(prepared, & &1.needs_embed?)
|
||||||
|
:ok = report_rebuild_started(on_progress, length(pending), "embedding entries")
|
||||||
|
|
||||||
|
case embed_pending(pending, on_progress) do
|
||||||
|
{:ok, vectors_by_post_id} -> {:ok, collect_rows(prepared, vectors_by_post_id, base_label)}
|
||||||
|
{:error, _reason} = error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_rows(prepared, vectors_by_post_id, base_label) do
|
||||||
|
{rows, _next_label} =
|
||||||
|
Enum.reduce(prepared, {[], base_label + 1}, fn entry, {acc, next_label} ->
|
||||||
|
if entry.needs_embed? do
|
||||||
|
vector = Map.fetch!(vectors_by_post_id, entry.post.id)
|
||||||
|
label = if entry.existing, do: entry.existing.label, else: next_label
|
||||||
|
bump = if entry.existing, do: 0, else: 1
|
||||||
|
|
||||||
|
row = [
|
||||||
|
label,
|
||||||
|
entry.post.id,
|
||||||
|
entry.post.project_id,
|
||||||
|
entry.content_hash,
|
||||||
|
encode_vector(vector)
|
||||||
|
]
|
||||||
|
|
||||||
|
{[row | acc], next_label + bump}
|
||||||
|
else
|
||||||
|
{acc, next_label}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
rows
|
||||||
|
end
|
||||||
|
|
||||||
|
defp embed_pending([], _on_progress), do: {:ok, %{}}
|
||||||
|
|
||||||
|
defp embed_pending(pending, on_progress) do
|
||||||
|
total = length(pending)
|
||||||
|
batch = batch_size()
|
||||||
|
|
||||||
|
pending
|
||||||
|
# Group by language so the lexical stub stems consistently; the neural
|
||||||
|
# backend is multilingual and ignores the language hint.
|
||||||
|
|> Enum.group_by(& &1.post.language)
|
||||||
|
|> Enum.reduce_while({%{}, 0}, fn {language, group}, acc ->
|
||||||
|
group
|
||||||
|
|> Enum.chunk_every(batch)
|
||||||
|
|> Enum.reduce_while(acc, fn chunk, {vectors, done} ->
|
||||||
|
case embed_many(Enum.map(chunk, & &1.raw_text), language) do
|
||||||
|
{:ok, chunk_vectors} ->
|
||||||
|
vectors =
|
||||||
|
chunk
|
||||||
|
|> Enum.zip(chunk_vectors)
|
||||||
|
|> Enum.reduce(vectors, fn {entry, vector}, acc ->
|
||||||
|
Map.put(acc, entry.post.id, vector)
|
||||||
|
end)
|
||||||
|
|
||||||
|
done = done + length(chunk)
|
||||||
|
:ok = report_rebuild_progress(on_progress, done, total, "embedding entries")
|
||||||
|
{:cont, {vectors, done}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:halt, {:error, reason}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:error, reason} -> {:halt, {:error, reason}}
|
||||||
|
accumulator -> {:cont, accumulator}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
{vectors, _done} -> {:ok, vectors}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp batch_upsert_keys([]), do: :ok
|
||||||
|
|
||||||
|
defp batch_upsert_keys(rows) do
|
||||||
|
rows
|
||||||
|
|> Enum.chunk_every(@key_batch_size)
|
||||||
|
|> Enum.each(fn chunk ->
|
||||||
|
placeholders = Enum.map_join(chunk, ", ", fn _ -> "(?, ?, ?, ?, ?)" end)
|
||||||
|
params = List.flatten(chunk)
|
||||||
|
|
||||||
|
Repo.query!(
|
||||||
|
"INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector) VALUES #{placeholders} ON CONFLICT(label) DO UPDATE SET content_hash = excluded.content_hash, vector = excluded.vector",
|
||||||
|
params
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
def remove_post(post_id) when is_binary(post_id) do
|
def remove_post(post_id) when is_binary(post_id) do
|
||||||
project_id =
|
project_id =
|
||||||
@@ -227,29 +378,21 @@ defmodule BDS.Embeddings do
|
|||||||
order_by: [asc: post.created_at, asc: post.slug]
|
order_by: [asc: post.created_at, asc: post.slug]
|
||||||
)
|
)
|
||||||
|
|
||||||
Enum.each(posts, fn post ->
|
existing_keys = preload_keys_by_post_id(project_id)
|
||||||
body = resolve_post_body(post)
|
|
||||||
content_hash = hash_text(compose_embedding_source(post.title, body))
|
|
||||||
|
|
||||||
case Repo.get_by(Key, post_id: post.id, project_id: project_id) do
|
|
||||||
%Key{content_hash: ^content_hash} ->
|
|
||||||
:ok
|
|
||||||
|
|
||||||
_other ->
|
|
||||||
:ok =
|
|
||||||
sync_post_if_enabled(
|
|
||||||
%{post | content: if(post.content in [nil, ""], do: body, else: post.content)},
|
|
||||||
refresh_index: false
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
|
case build_key_rows(posts, existing_keys, max_label_value(), nil, false) do
|
||||||
|
{:ok, rows} ->
|
||||||
|
batch_upsert_keys(rows)
|
||||||
:ok = rebuild_snapshot(project_id)
|
:ok = rebuild_snapshot(project_id)
|
||||||
|
|
||||||
indexed =
|
indexed =
|
||||||
Repo.all(from key in Key, where: key.project_id == ^project_id, select: key.post_id)
|
Repo.all(from key in Key, where: key.project_id == ^project_id, select: key.post_id)
|
||||||
|
|
||||||
{:ok, indexed}
|
{:ok, indexed}
|
||||||
|
|
||||||
|
{:error, _reason} = error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
else
|
else
|
||||||
{:ok, []}
|
{:ok, []}
|
||||||
end
|
end
|
||||||
@@ -263,28 +406,28 @@ defmodule BDS.Embeddings do
|
|||||||
{:error, :not_found} ->
|
{:error, :not_found} ->
|
||||||
{:ok, []}
|
{:ok, []}
|
||||||
|
|
||||||
{:ok, post, source_vector} ->
|
{:ok, _post, nil} ->
|
||||||
similar =
|
{:ok, []}
|
||||||
case Index.neighbors(post.project_id, post.id, limit) do
|
|
||||||
|
{:ok, post, %Key{} = key} ->
|
||||||
|
{:ok, query_similar(post.project_id, key, limit)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Queries the HNSW index for a post's neighbours, rebuilding the index from
|
||||||
|
# the DB vectors if it is not currently loaded (e.g. after a restart).
|
||||||
|
defp query_similar(project_id, %Key{} = key, limit) do
|
||||||
|
case Index.neighbors(project_id, key.label, key.vector, limit) do
|
||||||
{:ok, neighbors} ->
|
{:ok, neighbors} ->
|
||||||
neighbors
|
neighbors
|
||||||
|
|
||||||
{:error, :missing} ->
|
{:error, :missing} ->
|
||||||
Repo.all(
|
:ok = rebuild_snapshot(project_id)
|
||||||
from key in Key,
|
|
||||||
where: key.project_id == ^post.project_id and key.post_id != ^post.id
|
|
||||||
)
|
|
||||||
|> Enum.map(fn key ->
|
|
||||||
%{
|
|
||||||
post_id: key.post_id,
|
|
||||||
score: cosine_similarity(source_vector, decode_vector(key.vector))
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|> Enum.sort_by(& &1.score, :desc)
|
|
||||||
|> Enum.take(max(limit, 0))
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, similar}
|
case Index.neighbors(project_id, key.label, key.vector, limit) do
|
||||||
|
{:ok, neighbors} -> neighbors
|
||||||
|
{:error, :missing} -> []
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -297,8 +440,12 @@ defmodule BDS.Embeddings do
|
|||||||
{:error, :not_found} ->
|
{:error, :not_found} ->
|
||||||
{:ok, %{}}
|
{:ok, %{}}
|
||||||
|
|
||||||
{:ok, post, source_vector} ->
|
{:ok, _post, nil} ->
|
||||||
|
{:ok, %{}}
|
||||||
|
|
||||||
|
{:ok, post, %Key{} = source_key} ->
|
||||||
target_ids = Enum.uniq(target_post_ids)
|
target_ids = Enum.uniq(target_post_ids)
|
||||||
|
source_vector = decode_vector(source_key.vector)
|
||||||
|
|
||||||
scores =
|
scores =
|
||||||
Repo.all(
|
Repo.all(
|
||||||
@@ -354,47 +501,19 @@ defmodule BDS.Embeddings do
|
|||||||
if enabled_for_project?(project_id) do
|
if enabled_for_project?(project_id) do
|
||||||
on_progress = progress_callback(opts)
|
on_progress = progress_callback(opts)
|
||||||
dismissed = dismissed_pair_keys(project_id)
|
dismissed = dismissed_pair_keys(project_id)
|
||||||
|
entries = load_index_entries(project_id)
|
||||||
|
|
||||||
|
pairs =
|
||||||
|
case duplicate_pairs_with_rebuild(project_id, entries, on_progress) do
|
||||||
|
{:ok, pairs} -> pairs
|
||||||
|
{:error, :missing} -> []
|
||||||
|
end
|
||||||
|
|
||||||
duplicates =
|
duplicates =
|
||||||
case Index.duplicate_pairs(project_id, @duplicate_threshold, on_progress: on_progress) do
|
|
||||||
{:ok, pairs} ->
|
|
||||||
pairs
|
pairs
|
||||||
|> Enum.reject(fn pair -> pair_key(pair.post_id_a, pair.post_id_b) in dismissed end)
|
|> Enum.reject(fn pair -> pair_key(pair.post_id_a, pair.post_id_b) in dismissed end)
|
||||||
|> enrich_duplicate_pairs(project_id)
|
|> enrich_duplicate_pairs(project_id)
|
||||||
|
|
||||||
{:error, :missing} ->
|
|
||||||
keys =
|
|
||||||
Repo.all(
|
|
||||||
from key in Key,
|
|
||||||
where: key.project_id == ^project_id,
|
|
||||||
order_by: [asc: key.post_id]
|
|
||||||
)
|
|
||||||
|
|
||||||
total_keys = length(keys)
|
|
||||||
|
|
||||||
:ok = report_rebuild_started(on_progress, total_keys, "embedding entries")
|
|
||||||
|
|
||||||
keys
|
|
||||||
|> Enum.with_index(1)
|
|
||||||
|> Enum.flat_map(fn {left, index} ->
|
|
||||||
:ok = report_rebuild_progress(on_progress, index, total_keys, "embedding entries")
|
|
||||||
|
|
||||||
for right <- keys,
|
|
||||||
left.post_id < right.post_id,
|
|
||||||
pair_key(left.post_id, right.post_id) not in dismissed,
|
|
||||||
similarity =
|
|
||||||
cosine_similarity(decode_vector(left.vector), decode_vector(right.vector)),
|
|
||||||
similarity >= @duplicate_threshold do
|
|
||||||
%{
|
|
||||||
post_id_a: left.post_id,
|
|
||||||
post_id_b: right.post_id,
|
|
||||||
score: similarity
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|> enrich_duplicate_pairs(project_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
:ok = report_rebuild_phase(on_progress, 0.99, "Resolving duplicate candidates")
|
:ok = report_rebuild_phase(on_progress, 0.99, "Resolving duplicate candidates")
|
||||||
{:ok, duplicates}
|
{:ok, duplicates}
|
||||||
else
|
else
|
||||||
@@ -457,17 +576,33 @@ defmodule BDS.Embeddings do
|
|||||||
with {:ok, post} <- fetch_post(post_id) do
|
with {:ok, post} <- fetch_post(post_id) do
|
||||||
if enabled_for_project?(post.project_id) do
|
if enabled_for_project?(post.project_id) do
|
||||||
:ok = ensure_key(post)
|
:ok = ensure_key(post)
|
||||||
|
{:ok, post, Repo.get_by(Key, post_id: post.id, project_id: post.project_id)}
|
||||||
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
|
|
||||||
nil -> {:ok, post, []}
|
|
||||||
key -> {:ok, post, decode_vector(key.vector)}
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
{:disabled, post.project_id}
|
{:disabled, post.project_id}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp duplicate_pairs_with_rebuild(project_id, entries, on_progress) do
|
||||||
|
case Index.duplicate_pairs(project_id, entries, @duplicate_threshold, on_progress: on_progress) do
|
||||||
|
{:ok, pairs} ->
|
||||||
|
{:ok, pairs}
|
||||||
|
|
||||||
|
{:error, :missing} ->
|
||||||
|
:ok = rebuild_snapshot(project_id)
|
||||||
|
Index.duplicate_pairs(project_id, entries, @duplicate_threshold, on_progress: on_progress)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_index_entries(project_id) do
|
||||||
|
Repo.all(
|
||||||
|
from key in Key,
|
||||||
|
where: key.project_id == ^project_id,
|
||||||
|
order_by: [asc: key.post_id]
|
||||||
|
)
|
||||||
|
|> Enum.map(fn key -> %{label: key.label, post_id: key.post_id, vector: key.vector} end)
|
||||||
|
end
|
||||||
|
|
||||||
defp ensure_key(%Post{} = post) do
|
defp ensure_key(%Post{} = post) do
|
||||||
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
|
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
|
||||||
nil -> sync_post(post)
|
nil -> sync_post(post)
|
||||||
@@ -574,11 +709,42 @@ defmodule BDS.Embeddings do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp embed_text(raw_text, language) do
|
defp embed_text(raw_text, language) do
|
||||||
configured_backend().embed("query: " <> raw_text, language: language)
|
# Per-backend preprocessing (e5 "query: " prefix, pooling, normalisation)
|
||||||
|
# is the backend's responsibility — see BDS.Embeddings.Backends.Neural.
|
||||||
|
configured_backend().embed(raw_text, language: language)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Embeds a batch of texts in one shot. Backends that implement the optional
|
||||||
|
# embed_many/2 callback (e.g. the neural backend, which feeds them through the
|
||||||
|
# model as a single batched inference run) handle the whole list; others fall
|
||||||
|
# back to sequential single embeds.
|
||||||
|
defp embed_many(texts, language) do
|
||||||
|
backend = configured_backend()
|
||||||
|
|
||||||
|
if function_exported?(backend, :embed_many, 2) do
|
||||||
|
backend.embed_many(texts, language: language)
|
||||||
|
else
|
||||||
|
Enum.reduce_while(texts, {:ok, []}, fn text, {:ok, acc} ->
|
||||||
|
case backend.embed(text, language: language) do
|
||||||
|
{:ok, vector} -> {:cont, {:ok, [vector | acc]}}
|
||||||
|
{:error, _reason} = error -> {:halt, error}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:ok, vectors} -> {:ok, Enum.reverse(vectors)}
|
||||||
|
{:error, _reason} = error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp batch_size do
|
||||||
|
Application.get_env(:bds, :embeddings, [])
|
||||||
|
|> Keyword.get(:batch_size, 16)
|
||||||
|
|> max(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp rebuild_snapshot(project_id) do
|
defp rebuild_snapshot(project_id) do
|
||||||
Index.rebuild(project_id, model_id: model_id(), dimensions: dimensions())
|
Index.put(project_id, dimensions(), load_index_entries(project_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp progress_callback(opts), do: ProgressReporter.callback(opts)
|
defp progress_callback(opts), do: ProgressReporter.callback(opts)
|
||||||
@@ -603,13 +769,6 @@ defmodule BDS.Embeddings do
|
|||||||
defp report_rebuild_phase(callback, value, label),
|
defp report_rebuild_phase(callback, value, label),
|
||||||
do: ProgressReporter.report_phase(callback, value, label)
|
do: ProgressReporter.report_phase(callback, value, label)
|
||||||
|
|
||||||
defp snapshot_content_hash(project_id, post_id) do
|
|
||||||
case Index.read(project_id) do
|
|
||||||
{:ok, snapshot} -> get_in(snapshot, ["entries", post_id, "content_hash"])
|
|
||||||
_other -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp current_embedding_status(nil, _expected_hash), do: "missing"
|
defp current_embedding_status(nil, _expected_hash), do: "missing"
|
||||||
|
|
||||||
defp current_embedding_status(%Key{vector: vector}, _expected_hash) when vector in [nil, ""],
|
defp current_embedding_status(%Key{vector: vector}, _expected_hash) when vector in [nil, ""],
|
||||||
@@ -645,8 +804,22 @@ defmodule BDS.Embeddings do
|
|||||||
|
|
||||||
defp hash_text(text), do: :crypto.hash(:sha256, text) |> Base.encode16(case: :lower)
|
defp hash_text(text), do: :crypto.hash(:sha256, text) |> Base.encode16(case: :lower)
|
||||||
|
|
||||||
|
# Vectors are persisted as a packed little-endian Float32 BLOB
|
||||||
|
# (`dimensions` * 4 bytes; 1536 bytes for multilingual-e5-small) per the
|
||||||
|
# VectorCacheInDb invariant in specs/embedding.allium.
|
||||||
|
defp encode_vector(values) when is_list(values) do
|
||||||
|
for value <- values, into: <<>>, do: <<float32(value)::float-32-little>>
|
||||||
|
end
|
||||||
|
|
||||||
|
defp float32(value) when is_float(value), do: value
|
||||||
|
defp float32(value) when is_integer(value), do: value * 1.0
|
||||||
|
|
||||||
defp decode_vector(nil), do: []
|
defp decode_vector(nil), do: []
|
||||||
defp decode_vector(vector), do: Jason.decode!(vector)
|
defp decode_vector(<<>>), do: []
|
||||||
|
|
||||||
|
defp decode_vector(binary) when is_binary(binary) do
|
||||||
|
for <<value::float-32-little <- binary>>, do: value
|
||||||
|
end
|
||||||
|
|
||||||
defp cosine_similarity([], _other), do: 0.0
|
defp cosine_similarity([], _other), do: 0.0
|
||||||
defp cosine_similarity(_vector, []), do: 0.0
|
defp cosine_similarity(_vector, []), do: 0.0
|
||||||
|
|||||||
@@ -3,4 +3,15 @@ defmodule BDS.Embeddings.Backend do
|
|||||||
|
|
||||||
@callback model_info() :: %{model_id: String.t(), dimensions: pos_integer()}
|
@callback model_info() :: %{model_id: String.t(), dimensions: pos_integer()}
|
||||||
@callback embed(String.t(), keyword()) :: {:ok, [number()]} | {:error, term()}
|
@callback embed(String.t(), keyword()) :: {:ok, [number()]} | {:error, term()}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Embeds a list of texts in a single call.
|
||||||
|
|
||||||
|
Backends that can amortise work across inputs (e.g. running the neural model
|
||||||
|
on a batched tensor) should implement this. The result list is aligned with
|
||||||
|
the input list. Optional — callers fall back to repeated `embed/2`.
|
||||||
|
"""
|
||||||
|
@callback embed_many([String.t()], keyword()) :: {:ok, [[number()]]} | {:error, term()}
|
||||||
|
|
||||||
|
@optional_callbacks embed_many: 2
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
defmodule BDS.Embeddings.Backends.InApp do
|
defmodule BDS.Embeddings.Backends.InApp do
|
||||||
@moduledoc false
|
@moduledoc """
|
||||||
|
Deterministic lexical embedding stub.
|
||||||
|
|
||||||
|
This backend does NOT satisfy the `RealNeuralModel` invariant — it projects
|
||||||
|
stemmed tokens and bigrams into a sparse hashed vector. It exists only as an
|
||||||
|
offline, dependency-free fallback for tests and environments where the neural
|
||||||
|
model (see `BDS.Embeddings.Backends.Neural`) cannot be loaded. Production and
|
||||||
|
development use the neural backend.
|
||||||
|
"""
|
||||||
|
|
||||||
@behaviour BDS.Embeddings.Backend
|
@behaviour BDS.Embeddings.Backend
|
||||||
|
|
||||||
@@ -29,6 +37,17 @@ defmodule BDS.Embeddings.Backends.InApp do
|
|||||||
{:ok, vector}
|
{:ok, vector}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def embed_many(texts, opts) when is_list(texts) and is_list(opts) do
|
||||||
|
vectors =
|
||||||
|
Enum.map(texts, fn text ->
|
||||||
|
{:ok, vector} = embed(text, opts)
|
||||||
|
vector
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, vectors}
|
||||||
|
end
|
||||||
|
|
||||||
defp tokenize(text) do
|
defp tokenize(text) do
|
||||||
Regex.scan(~r/[[:alnum:]]+/u, String.downcase(text))
|
Regex.scan(~r/[[:alnum:]]+/u, String.downcase(text))
|
||||||
|> List.flatten()
|
|> List.flatten()
|
||||||
|
|||||||
152
lib/bds/embeddings/backends/neural.ex
Normal file
152
lib/bds/embeddings/backends/neural.ex
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
defmodule BDS.Embeddings.Backends.Neural do
|
||||||
|
@moduledoc """
|
||||||
|
Real on-device neural embedding backend.
|
||||||
|
|
||||||
|
Implements the `RealNeuralModel` and `ModelCaching` invariants from
|
||||||
|
`specs/embedding.allium`: embeddings are produced by the actual
|
||||||
|
multilingual-e5-small transformer (the `intfloat/multilingual-e5-small`
|
||||||
|
weights behind the `Xenova/multilingual-e5-small` identifier) via
|
||||||
|
Bumblebee + EXLA, never by a lexical approximation.
|
||||||
|
|
||||||
|
* Lazy-loaded — the model pipeline is built on the first embedding
|
||||||
|
request, not at application startup.
|
||||||
|
* Model files (~100 MB) are downloaded from the Hugging Face Hub on
|
||||||
|
first use and cached on disk (Bumblebee cache dir), persisting across
|
||||||
|
sessions and project switches.
|
||||||
|
* Text preprocessing follows the e5 convention: every input is prefixed
|
||||||
|
with `"query: "`, pooled with mean pooling over the attention mask, and
|
||||||
|
L2-normalised. This is what makes cross-language semantic similarity
|
||||||
|
work.
|
||||||
|
* Inference is batched. `embed_many/2` runs the model on `batch_size`
|
||||||
|
texts per compiled inference run instead of one at a time, which is the
|
||||||
|
dominant cost when (re)indexing large numbers of posts. The serving is
|
||||||
|
compiled for a fixed `batch_size`/`sequence_length` (configurable);
|
||||||
|
shorter sequences mean less wasted transformer compute.
|
||||||
|
|
||||||
|
EXLA on Apple Silicon runs on the CPU — XLA has no Metal/GPU backend. See
|
||||||
|
SPECGAPS A1-14c for the planned EMLX (Apple GPU via MLX) acceleration path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@behaviour BDS.Embeddings.Backend
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
@query_prefix "query: "
|
||||||
|
@embed_timeout :timer.minutes(10)
|
||||||
|
|
||||||
|
@default_model_id "Xenova/multilingual-e5-small"
|
||||||
|
@default_model_repo "intfloat/multilingual-e5-small"
|
||||||
|
@default_dimensions 384
|
||||||
|
@default_batch_size 16
|
||||||
|
@default_sequence_length 256
|
||||||
|
|
||||||
|
def child_spec(opts) do
|
||||||
|
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl BDS.Embeddings.Backend
|
||||||
|
def model_info do
|
||||||
|
config = config()
|
||||||
|
|
||||||
|
%{
|
||||||
|
model_id: Keyword.get(config, :model_id, @default_model_id),
|
||||||
|
dimensions: Keyword.get(config, :dimensions, @default_dimensions)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl BDS.Embeddings.Backend
|
||||||
|
def embed(text, _opts) when is_binary(text) do
|
||||||
|
case run([@query_prefix <> text]) do
|
||||||
|
{:ok, [vector]} -> {:ok, vector}
|
||||||
|
{:ok, _other} -> {:error, :unexpected_embedding_result}
|
||||||
|
{:error, _reason} = error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl BDS.Embeddings.Backend
|
||||||
|
def embed_many([], _opts), do: {:ok, []}
|
||||||
|
|
||||||
|
def embed_many(texts, _opts) when is_list(texts) do
|
||||||
|
run(Enum.map(texts, &(@query_prefix <> &1)))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run(prefixed_texts) do
|
||||||
|
GenServer.call(__MODULE__, {:embed, prefixed_texts}, @embed_timeout)
|
||||||
|
catch
|
||||||
|
:exit, reason -> {:error, {:embedding_backend_unavailable, reason}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl GenServer
|
||||||
|
def init(_opts), do: {:ok, %{serving: nil}}
|
||||||
|
|
||||||
|
@impl GenServer
|
||||||
|
def handle_call({:embed, texts}, _from, state) do
|
||||||
|
case ensure_serving(state) do
|
||||||
|
{:ok, %{serving: serving} = next_state} ->
|
||||||
|
vectors =
|
||||||
|
texts
|
||||||
|
|> Enum.chunk_every(batch_size())
|
||||||
|
|> Enum.flat_map(&run_chunk(serving, &1))
|
||||||
|
|
||||||
|
{:reply, {:ok, vectors}, next_state}
|
||||||
|
|
||||||
|
{:error, _reason} = error ->
|
||||||
|
{:reply, error, state}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
exception ->
|
||||||
|
{:reply, {:error, Exception.message(exception)}, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_chunk(serving, [single]) do
|
||||||
|
%{embedding: tensor} = Nx.Serving.run(serving, single)
|
||||||
|
[Nx.to_flat_list(tensor)]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_chunk(serving, chunk) do
|
||||||
|
serving
|
||||||
|
|> Nx.Serving.run(chunk)
|
||||||
|
|> Enum.map(fn %{embedding: tensor} -> Nx.to_flat_list(tensor) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_serving(%{serving: nil} = state) do
|
||||||
|
case build_serving() do
|
||||||
|
{:ok, serving} -> {:ok, %{state | serving: serving}}
|
||||||
|
{:error, _reason} = error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_serving(state), do: {:ok, state}
|
||||||
|
|
||||||
|
defp build_serving do
|
||||||
|
repo = {:hf, Keyword.get(config(), :model_repo, @default_model_repo)}
|
||||||
|
|
||||||
|
with {:ok, model_info} <- Bumblebee.load_model(repo),
|
||||||
|
{:ok, tokenizer} <- Bumblebee.load_tokenizer(repo) do
|
||||||
|
serving =
|
||||||
|
Bumblebee.Text.text_embedding(model_info, tokenizer,
|
||||||
|
output_pool: :mean_pooling,
|
||||||
|
output_attribute: :hidden_state,
|
||||||
|
embedding_processor: :l2_norm,
|
||||||
|
compile: [batch_size: batch_size(), sequence_length: sequence_length()],
|
||||||
|
defn_options: [compiler: EXLA]
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, serving}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp batch_size do
|
||||||
|
config() |> Keyword.get(:batch_size, @default_batch_size) |> max(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sequence_length do
|
||||||
|
config() |> Keyword.get(:sequence_length, @default_sequence_length) |> max(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp config, do: Application.get_env(:bds, :embeddings, [])
|
||||||
|
end
|
||||||
@@ -1,214 +1,342 @@
|
|||||||
defmodule BDS.Embeddings.Index do
|
defmodule BDS.Embeddings.Index do
|
||||||
@moduledoc false
|
@moduledoc """
|
||||||
|
Per-project approximate-nearest-neighbour index over post embeddings.
|
||||||
|
|
||||||
import Ecto.Query
|
Backed by an HNSW graph (hnswlib) per the A1-14b / `specs/embedding.allium`
|
||||||
|
requirement — cosine space, connectivity M=16, efConstruction=128,
|
||||||
|
efSearch=64. This replaces the previous O(n²) brute-force cosine snapshot:
|
||||||
|
building is O(n·log n) and queries are O(log n).
|
||||||
|
|
||||||
|
The process is intentionally **database-free**: callers (running in their own
|
||||||
|
process, e.g. under the test SQL sandbox) read embedding vectors from the DB
|
||||||
|
and hand them in. This GenServer owns only the in-memory HNSW graphs, the
|
||||||
|
`label → post_id` maps, and file persistence.
|
||||||
|
|
||||||
|
Persistence (DebouncedPersistence invariant): the index file
|
||||||
|
(`embeddings.usearch`) plus a small sidecar holding the dimension and the
|
||||||
|
label→post_id map are written behind a 5s debounce, and force-saved on
|
||||||
|
project switch / shutdown. On a cold query the index is lazily reloaded from
|
||||||
|
those files; if they are absent the caller rebuilds from the DB vectors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
alias BDS.Persistence
|
|
||||||
alias BDS.Embeddings.Key
|
|
||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.ProgressReporter
|
alias BDS.ProgressReporter
|
||||||
alias BDS.Repo
|
|
||||||
|
|
||||||
@neighbor_limit 21
|
@neighbor_limit 21
|
||||||
|
@debounce_ms 5_000
|
||||||
|
@space :cosine
|
||||||
|
@m 16
|
||||||
|
@ef_construction 128
|
||||||
|
@ef_search 64
|
||||||
|
|
||||||
|
# ─── Public API ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "On-disk path of the HNSW index file for a project."
|
||||||
def path(project_id) when is_binary(project_id) do
|
def path(project_id) when is_binary(project_id) do
|
||||||
Path.join(Projects.project_cache_dir(project_id), "embeddings.usearch")
|
Path.join(Projects.project_cache_dir(project_id), "embeddings.usearch")
|
||||||
end
|
end
|
||||||
|
|
||||||
def rebuild(project_id, opts) when is_binary(project_id) and is_list(opts) do
|
@doc """
|
||||||
model_id = Keyword.fetch!(opts, :model_id)
|
(Re)builds the index for a project from the given entries and schedules a
|
||||||
dimensions = Keyword.fetch!(opts, :dimensions)
|
debounced save. `entries` is a list of `%{label:, post_id:, vector:}` where
|
||||||
|
`vector` is the packed little-endian Float32 BLOB.
|
||||||
|
"""
|
||||||
|
def put(project_id, dimensions, entries)
|
||||||
|
when is_binary(project_id) and is_integer(dimensions) and is_list(entries) do
|
||||||
|
GenServer.call(__MODULE__, {:put, project_id, dimensions, entries}, :infinity)
|
||||||
|
end
|
||||||
|
|
||||||
keys =
|
@doc """
|
||||||
Repo.all(
|
Returns up to `limit` nearest neighbours of `query_vector` (the post's packed
|
||||||
from key in Key,
|
BLOB), excluding `query_label`. `{:error, :missing}` if no index is available.
|
||||||
where: key.project_id == ^project_id,
|
"""
|
||||||
order_by: [asc: key.post_id]
|
def neighbors(project_id, query_label, query_vector, limit)
|
||||||
|
when is_binary(project_id) and is_integer(query_label) and is_binary(query_vector) do
|
||||||
|
GenServer.call(__MODULE__, {:neighbors, project_id, query_label, query_vector, limit}, :infinity)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Finds near-duplicate pairs at/above `threshold` by querying the HNSW graph for
|
||||||
|
each entry's neighbours. `{:error, :missing}` if no index is available.
|
||||||
|
"""
|
||||||
|
def duplicate_pairs(project_id, entries, threshold, opts \\ [])
|
||||||
|
when is_binary(project_id) and is_list(entries) and is_number(threshold) do
|
||||||
|
GenServer.call(
|
||||||
|
__MODULE__,
|
||||||
|
{:duplicate_pairs, project_id, entries, threshold, opts},
|
||||||
|
:infinity
|
||||||
)
|
)
|
||||||
|
end
|
||||||
|
|
||||||
entries =
|
@doc "Forces a pending save for a project to disk now (e.g. on project switch)."
|
||||||
keys
|
def flush(project_id) when is_binary(project_id) do
|
||||||
|> Enum.map(fn key ->
|
GenServer.call(__MODULE__, {:flush, project_id}, :infinity)
|
||||||
vector = decode_vector(key.vector)
|
end
|
||||||
|
|
||||||
|
@doc "Forces all pending saves to disk now (e.g. on shutdown)."
|
||||||
|
def flush_all do
|
||||||
|
GenServer.call(__MODULE__, :flush_all, :infinity)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Drops the in-memory index for a project (e.g. on project deletion)."
|
||||||
|
def forget(project_id) when is_binary(project_id) do
|
||||||
|
GenServer.call(__MODULE__, {:forget, project_id}, :infinity)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ─── GenServer ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_opts) do
|
||||||
|
Process.flag(:trap_exit, true)
|
||||||
|
{:ok, %{}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:put, project_id, dimensions, entries}, _from, state) do
|
||||||
|
entry = build_entry(dimensions, entries)
|
||||||
|
state = state |> Map.put(project_id, entry) |> schedule_save(project_id)
|
||||||
|
{:reply, :ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:neighbors, project_id, query_label, query_vector, limit}, _from, state) do
|
||||||
|
case ensure_loaded(state, project_id) do
|
||||||
|
{:ok, %{index: nil}, state} ->
|
||||||
|
{:reply, {:error, :missing}, state}
|
||||||
|
|
||||||
|
{:ok, entry, state} ->
|
||||||
|
{:reply, {:ok, query_neighbors(entry, query_label, query_vector, limit)}, state}
|
||||||
|
|
||||||
|
{:missing, state} ->
|
||||||
|
{:reply, {:error, :missing}, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:duplicate_pairs, project_id, entries, threshold, opts}, _from, state) do
|
||||||
|
case ensure_loaded(state, project_id) do
|
||||||
|
{:ok, %{index: nil}, state} ->
|
||||||
|
{:reply, {:error, :missing}, state}
|
||||||
|
|
||||||
|
{:ok, entry, state} ->
|
||||||
|
{:reply, {:ok, scan_duplicates(entry, entries, threshold, opts)}, state}
|
||||||
|
|
||||||
|
{:missing, state} ->
|
||||||
|
{:reply, {:error, :missing}, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:flush, project_id}, _from, state) do
|
||||||
|
{:reply, :ok, save_now(state, project_id)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:flush_all, _from, state) do
|
||||||
|
state = Enum.reduce(Map.keys(state), state, &save_now(&2, &1))
|
||||||
|
{:reply, :ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:forget, project_id}, _from, state) do
|
||||||
|
case Map.get(state, project_id) do
|
||||||
|
%{timer: timer} when is_reference(timer) -> Process.cancel_timer(timer)
|
||||||
|
_other -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
{:reply, :ok, Map.delete(state, project_id)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:save, project_id}, state) do
|
||||||
|
{:noreply, save_now(state, project_id)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(_message, state), do: {:noreply, state}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def terminate(_reason, state) do
|
||||||
|
Enum.each(Map.keys(state), &save_now(state, &1))
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# ─── Build / query ──────────────────────────────────────────
|
||||||
|
|
||||||
|
defp build_entry(dimensions, []), do: %{index: nil, labels: %{}, dim: dimensions, timer: nil}
|
||||||
|
|
||||||
|
defp build_entry(dimensions, entries) do
|
||||||
|
count = length(entries)
|
||||||
|
{:ok, index} = HNSWLib.Index.new(@space, dimensions, count, m: @m, ef_construction: @ef_construction)
|
||||||
|
:ok = HNSWLib.Index.set_ef(index, @ef_search)
|
||||||
|
|
||||||
|
tensor =
|
||||||
|
entries
|
||||||
|
|> Enum.map(& &1.vector)
|
||||||
|
|> IO.iodata_to_binary()
|
||||||
|
|> Nx.from_binary(:f32)
|
||||||
|
|> Nx.reshape({count, dimensions})
|
||||||
|
|
||||||
|
:ok = HNSWLib.Index.add_items(index, tensor, ids: Enum.map(entries, & &1.label))
|
||||||
|
|
||||||
{key.post_id,
|
|
||||||
%{
|
%{
|
||||||
"label" => key.label,
|
index: index,
|
||||||
"content_hash" => key.content_hash,
|
labels: Map.new(entries, &{&1.label, &1.post_id}),
|
||||||
"neighbors" => neighbor_entries(keys, key, vector)
|
dim: dimensions,
|
||||||
}}
|
timer: nil
|
||||||
end)
|
|
||||||
|> Map.new()
|
|
||||||
|
|
||||||
payload = %{
|
|
||||||
"project_id" => project_id,
|
|
||||||
"model_id" => model_id,
|
|
||||||
"dimensions" => dimensions,
|
|
||||||
"updated_at" => Persistence.now_ms(),
|
|
||||||
"entries" => entries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
write_snapshot(path(project_id), payload, project_id)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def read(project_id) when is_binary(project_id) do
|
defp query_neighbors(%{index: index, labels: labels}, query_label, query_vector, limit) do
|
||||||
project_id
|
case query(index, query_vector, limit + 1) do
|
||||||
|> candidate_paths()
|
[] ->
|
||||||
|> read_snapshot_paths()
|
[]
|
||||||
end
|
|
||||||
|
|
||||||
def neighbors(project_id, post_id, limit) when is_binary(project_id) and is_binary(post_id) do
|
results ->
|
||||||
with {:ok, snapshot} <- read(project_id),
|
results
|
||||||
%{} = entry <- get_in(snapshot, ["entries", post_id]) do
|
|> Enum.reject(fn {label, _score} -> label == query_label end)
|
||||||
entry
|
|> Enum.map(fn {label, score} -> %{post_id: Map.get(labels, label), score: score} end)
|
||||||
|> Map.get("neighbors", [])
|
|> Enum.reject(&is_nil(&1.post_id))
|
||||||
|> Enum.take(max(limit, 0))
|
|> Enum.take(max(limit, 0))
|
||||||
|> Enum.map(fn neighbor ->
|
|
||||||
%{
|
|
||||||
post_id: neighbor["post_id"],
|
|
||||||
score: neighbor["score"]
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|> then(&{:ok, &1})
|
|
||||||
else
|
|
||||||
_ -> {:error, :missing}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def duplicate_pairs(project_id, threshold, opts \\ []) when is_binary(project_id) do
|
defp scan_duplicates(%{index: index, labels: labels}, entries, threshold, opts) do
|
||||||
with {:ok, snapshot} <- read(project_id) do
|
on_progress = ProgressReporter.callback(opts)
|
||||||
entries = Map.get(snapshot, "entries", %{})
|
total = length(entries)
|
||||||
entry_count = map_size(entries)
|
:ok = report_scan_started(on_progress, total, "embedding entries")
|
||||||
on_progress = progress_callback(opts)
|
|
||||||
|
|
||||||
:ok = report_scan_started(on_progress, entry_count, "embedding entries")
|
|
||||||
|
|
||||||
pairs =
|
|
||||||
entries
|
entries
|
||||||
|> Enum.with_index(1)
|
|> Enum.with_index(1)
|
||||||
|> Enum.flat_map(fn {{post_id, entry}, index} ->
|
|> Enum.flat_map(fn {entry, position} ->
|
||||||
:ok = report_scan_progress(on_progress, index, entry_count, "embedding entries")
|
:ok = report_scan_progress(on_progress, position, total, "embedding entries")
|
||||||
|
|
||||||
entry
|
index
|
||||||
|> Map.get("neighbors", [])
|
|> query(entry.vector, @neighbor_limit)
|
||||||
|> Enum.filter(&(&1["score"] >= threshold))
|
|> Enum.reject(fn {label, _score} -> label == entry.label end)
|
||||||
|> Enum.map(fn neighbor ->
|
|> Enum.map(fn {label, score} -> {Map.get(labels, label), score} end)
|
||||||
{post_id_a, post_id_b} = sort_pair(post_id, neighbor["post_id"])
|
|> Enum.filter(fn {post_id, score} -> not is_nil(post_id) and score >= threshold end)
|
||||||
|
|> Enum.map(fn {other_post_id, score} ->
|
||||||
{{post_id_a, post_id_b},
|
{post_id_a, post_id_b} = sort_pair(entry.post_id, other_post_id)
|
||||||
%{
|
{{post_id_a, post_id_b}, %{post_id_a: post_id_a, post_id_b: post_id_b, score: score}}
|
||||||
post_id_a: post_id_a,
|
|
||||||
post_id_b: post_id_b,
|
|
||||||
score: neighbor["score"]
|
|
||||||
}}
|
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|> Map.new()
|
|> Map.new()
|
||||||
|> Map.values()
|
|> Map.values()
|
||||||
|> Enum.sort_by(& &1.score, :desc)
|
|> Enum.sort_by(& &1.score, :desc)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok, pairs}
|
# Runs a knn query and returns [{label, similarity}] sorted by descending
|
||||||
else
|
# similarity. Cosine distance is converted to similarity as max(0, 1 - d).
|
||||||
_ -> {:error, :missing}
|
defp query(index, query_vector, k) do
|
||||||
|
case HNSWLib.Index.get_current_count(index) do
|
||||||
|
{:ok, count} when count > 0 ->
|
||||||
|
clamped = min(k, count)
|
||||||
|
|
||||||
|
case HNSWLib.Index.knn_query(index, query_vector, k: clamped) do
|
||||||
|
{:ok, labels, distances} ->
|
||||||
|
Enum.zip(
|
||||||
|
Nx.to_flat_list(labels),
|
||||||
|
Enum.map(Nx.to_flat_list(distances), fn distance -> max(0.0, 1.0 - distance) end)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
[]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp neighbor_entries(keys, current_key, current_vector) do
|
# ─── Persistence ────────────────────────────────────────────
|
||||||
keys
|
|
||||||
|> Enum.reject(&(&1.post_id == current_key.post_id))
|
defp schedule_save(state, project_id) do
|
||||||
|> Enum.map(fn other_key ->
|
entry = Map.fetch!(state, project_id)
|
||||||
%{
|
if is_reference(entry.timer), do: Process.cancel_timer(entry.timer)
|
||||||
"post_id" => other_key.post_id,
|
timer = Process.send_after(self(), {:save, project_id}, @debounce_ms)
|
||||||
"label" => other_key.label,
|
Map.put(state, project_id, %{entry | timer: timer})
|
||||||
"score" => cosine_similarity(current_vector, decode_vector(other_key.vector))
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|> Enum.sort_by(& &1["score"], :desc)
|
|
||||||
|> Enum.take(@neighbor_limit)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp write_snapshot(snapshot_path, payload, project_id) do
|
defp save_now(state, project_id) do
|
||||||
:ok = Persistence.atomic_write(snapshot_path, Jason.encode!(payload))
|
case Map.get(state, project_id) do
|
||||||
legacy_path = legacy_path(snapshot_path)
|
nil ->
|
||||||
|
state
|
||||||
|
|
||||||
if File.exists?(legacy_path) do
|
entry ->
|
||||||
File.rm(legacy_path)
|
if is_reference(entry.timer), do: Process.cancel_timer(entry.timer)
|
||||||
|
persist(project_id, entry)
|
||||||
|
Map.put(state, project_id, %{entry | timer: nil})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
cleanup_legacy_project_snapshots(project_id, snapshot_path)
|
defp persist(_project_id, %{index: nil}), do: :ok
|
||||||
|
|
||||||
|
defp persist(project_id, %{index: index, labels: labels, dim: dim}) do
|
||||||
|
index_path = path(project_id)
|
||||||
|
File.mkdir_p!(Path.dirname(index_path))
|
||||||
|
HNSWLib.Index.save_index(index, index_path)
|
||||||
|
write_meta(index_path, dim, labels)
|
||||||
:ok
|
:ok
|
||||||
|
rescue
|
||||||
|
_exception -> :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp candidate_paths(project_id) do
|
defp write_meta(index_path, dim, labels) do
|
||||||
current_snapshot_path = path(project_id)
|
payload = %{
|
||||||
legacy_project_snapshot_path = legacy_project_snapshot_path(project_id)
|
"dim" => dim,
|
||||||
|
"labels" => Enum.map(labels, fn {label, post_id} -> [label, post_id] end)
|
||||||
|
}
|
||||||
|
|
||||||
[
|
File.write(meta_path(index_path), Jason.encode!(payload))
|
||||||
current_snapshot_path,
|
|
||||||
legacy_path(current_snapshot_path),
|
|
||||||
legacy_project_snapshot_path,
|
|
||||||
legacy_project_snapshot_path && legacy_path(legacy_project_snapshot_path)
|
|
||||||
]
|
|
||||||
|> Enum.filter(&is_binary/1)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp read_snapshot_paths([]), do: {:error, :missing}
|
defp ensure_loaded(state, project_id) do
|
||||||
|
case Map.get(state, project_id) do
|
||||||
|
nil ->
|
||||||
|
case load_from_disk(project_id) do
|
||||||
|
{:ok, entry} -> {:ok, entry, Map.put(state, project_id, entry)}
|
||||||
|
:error -> {:missing, state}
|
||||||
|
end
|
||||||
|
|
||||||
defp read_snapshot_paths([snapshot_path | rest]) do
|
entry ->
|
||||||
case File.read(snapshot_path) do
|
{:ok, entry, state}
|
||||||
{:ok, contents} -> {:ok, Jason.decode!(contents)}
|
|
||||||
{:error, :enoent} -> read_snapshot_paths(rest)
|
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp cleanup_legacy_project_snapshots(project_id, snapshot_path) do
|
defp load_from_disk(project_id) do
|
||||||
current_paths = [snapshot_path, legacy_path(snapshot_path)]
|
index_path = path(project_id)
|
||||||
|
|
||||||
project_id
|
with {:ok, %{dim: dim, labels: labels}} <- read_meta(index_path),
|
||||||
|> legacy_project_snapshot_path()
|
true <- File.exists?(index_path),
|
||||||
|> then(fn legacy_snapshot_path ->
|
{:ok, index} <- HNSWLib.Index.load_index(@space, dim, index_path) do
|
||||||
[legacy_snapshot_path, legacy_snapshot_path && legacy_path(legacy_snapshot_path)]
|
:ok = HNSWLib.Index.set_ef(index, @ef_search)
|
||||||
end)
|
{:ok, %{index: index, labels: labels, dim: dim, timer: nil}}
|
||||||
|> Enum.filter(&is_binary/1)
|
else
|
||||||
|> Enum.reject(&(&1 in current_paths))
|
_other -> :error
|
||||||
|> Enum.each(fn legacy_snapshot_path ->
|
|
||||||
if File.exists?(legacy_snapshot_path) do
|
|
||||||
File.rm(legacy_snapshot_path)
|
|
||||||
end
|
end
|
||||||
end)
|
rescue
|
||||||
|
_exception -> :error
|
||||||
end
|
end
|
||||||
|
|
||||||
defp legacy_project_snapshot_path(project_id) do
|
defp read_meta(index_path) do
|
||||||
case Projects.get_project(project_id) do
|
with {:ok, contents} <- File.read(meta_path(index_path)),
|
||||||
nil -> nil
|
{:ok, %{"dim" => dim, "labels" => labels}} <- Jason.decode(contents) do
|
||||||
project -> Path.join(Projects.project_data_dir(project), "embeddings.usearch")
|
{:ok,
|
||||||
|
%{
|
||||||
|
dim: dim,
|
||||||
|
labels: Map.new(labels, fn [label, post_id] -> {label, post_id} end)
|
||||||
|
}}
|
||||||
|
else
|
||||||
|
_other -> :error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp legacy_path(snapshot_path) do
|
defp meta_path(index_path), do: index_path <> ".meta.json"
|
||||||
Path.join(Path.dirname(snapshot_path), "embeddings.index.json")
|
|
||||||
end
|
|
||||||
|
|
||||||
defp decode_vector(nil), do: []
|
|
||||||
defp decode_vector(vector), do: Jason.decode!(vector)
|
|
||||||
|
|
||||||
defp cosine_similarity([], _other), do: 0.0
|
|
||||||
defp cosine_similarity(_vector, []), do: 0.0
|
|
||||||
|
|
||||||
defp cosine_similarity(left, right) do
|
|
||||||
Enum.zip(left, right)
|
|
||||||
|> Enum.reduce(0.0, fn {left_value, right_value}, acc -> acc + left_value * right_value end)
|
|
||||||
|> max(0.0)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp sort_pair(post_id_a, post_id_b) when post_id_a <= post_id_b, do: {post_id_a, post_id_b}
|
defp sort_pair(post_id_a, post_id_b) when post_id_a <= post_id_b, do: {post_id_a, post_id_b}
|
||||||
defp sort_pair(post_id_a, post_id_b), do: {post_id_b, post_id_a}
|
defp sort_pair(post_id_a, post_id_b), do: {post_id_b, post_id_a}
|
||||||
|
|
||||||
defp progress_callback(opts), do: ProgressReporter.callback(opts)
|
|
||||||
|
|
||||||
defp report_scan_started(callback, total, label) do
|
defp report_scan_started(callback, total, label) do
|
||||||
ProgressReporter.report_count_started(callback, total, label,
|
ProgressReporter.report_count_started(callback, total, label,
|
||||||
verb: "Scanning",
|
verb: "Scanning",
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ defmodule BDS.Embeddings.Key do
|
|||||||
belongs_to :project, BDS.Projects.Project, type: :string
|
belongs_to :project, BDS.Projects.Project, type: :string
|
||||||
|
|
||||||
field :content_hash, :string
|
field :content_hash, :string
|
||||||
field :vector, :string
|
# Packed little-endian Float32 BLOB (dimensions * 4 bytes), per the
|
||||||
|
# VectorCacheInDb invariant in specs/embedding.allium.
|
||||||
|
field :vector, :binary
|
||||||
end
|
end
|
||||||
|
|
||||||
def changeset(key, attrs) do
|
def changeset(key, attrs) do
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ defmodule BDS.Generation.Data do
|
|||||||
main = String.downcase(to_string(main_language || ""))
|
main = String.downcase(to_string(main_language || ""))
|
||||||
|
|
||||||
Enum.map(posts, fn post ->
|
Enum.map(posts, fn post ->
|
||||||
post_language = String.downcase(to_string(Map.get(post, :language) || ""))
|
post_language = String.downcase(to_string(post.language || ""))
|
||||||
effective_language = if post_language == "", do: main, else: post_language
|
effective_language = if post_language == "", do: main, else: post_language
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
@@ -373,18 +373,18 @@ defmodule BDS.Generation.Data do
|
|||||||
excerpt: translation.excerpt,
|
excerpt: translation.excerpt,
|
||||||
content: nil,
|
content: nil,
|
||||||
status: :published,
|
status: :published,
|
||||||
author: Map.get(post, :author),
|
author: post.author,
|
||||||
created_at: post.created_at,
|
created_at: post.created_at,
|
||||||
updated_at: translation.updated_at,
|
updated_at: translation.updated_at,
|
||||||
published_at: translation.published_at || post.published_at,
|
published_at: translation.published_at || post.published_at,
|
||||||
file_path: translation.file_path,
|
file_path: translation.file_path,
|
||||||
tags: Map.get(post, :tags, []),
|
tags: post.tags,
|
||||||
categories: Map.get(post, :categories, []),
|
categories: post.categories,
|
||||||
template_slug: Map.get(post, :template_slug),
|
template_slug: post.template_slug,
|
||||||
language: translation.language,
|
language: translation.language,
|
||||||
do_not_translate: Map.get(post, :do_not_translate, false),
|
do_not_translate: post.do_not_translate,
|
||||||
translation_source_slug: post.slug,
|
translation_source_slug: post.slug,
|
||||||
translation_canonical_language: Map.get(post, :language),
|
translation_canonical_language: post.language,
|
||||||
translation_file_path: translation.file_path
|
translation_file_path: translation.file_path
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ defmodule BDS.Generation.Outputs do
|
|||||||
import BDS.Generation.Renderers
|
import BDS.Generation.Renderers
|
||||||
import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1]
|
import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1]
|
||||||
|
|
||||||
|
alias BDS.Rendering.TemplateSelection
|
||||||
|
|
||||||
@spec additional_languages(map()) :: [String.t()]
|
@spec additional_languages(map()) :: [String.t()]
|
||||||
def additional_languages(plan) do
|
def additional_languages(plan) do
|
||||||
Enum.reject(plan.blog_languages, &(&1 == plan.language))
|
Enum.reject(plan.blog_languages, &(&1 == plan.language))
|
||||||
@@ -21,7 +23,7 @@ defmodule BDS.Generation.Outputs do
|
|||||||
|
|
||||||
Enum.reject(route_posts, fn post ->
|
Enum.reject(route_posts, fn post ->
|
||||||
is_binary(Map.get(post, :translation_source_slug)) and
|
is_binary(Map.get(post, :translation_source_slug)) and
|
||||||
MapSet.member?(subtree_languages, to_string(Map.get(post, :language)))
|
MapSet.member?(subtree_languages, to_string(post.language))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -80,10 +82,12 @@ defmodule BDS.Generation.Outputs do
|
|||||||
def category_route_paths(plan, posts_by_category, route_language) do
|
def category_route_paths(plan, posts_by_category, route_language) do
|
||||||
if :category in plan.sections do
|
if :category in plan.sections do
|
||||||
Enum.flat_map(posts_by_category, fn {category, posts} ->
|
Enum.flat_map(posts_by_category, fn {category, posts} ->
|
||||||
|
post_count = length(posts)
|
||||||
|
|
||||||
paginated_archive_paths(
|
paginated_archive_paths(
|
||||||
route_language,
|
route_language,
|
||||||
["category", archive_route_segment(category)],
|
["category", archive_route_segment(category)],
|
||||||
length(posts),
|
post_count,
|
||||||
plan.max_posts_per_page
|
plan.max_posts_per_page
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
@@ -96,10 +100,12 @@ defmodule BDS.Generation.Outputs do
|
|||||||
def tag_route_paths(plan, posts_by_tag, route_language) do
|
def tag_route_paths(plan, posts_by_tag, route_language) do
|
||||||
if :tag in plan.sections do
|
if :tag in plan.sections do
|
||||||
Enum.flat_map(posts_by_tag, fn {tag, posts} ->
|
Enum.flat_map(posts_by_tag, fn {tag, posts} ->
|
||||||
|
post_count = length(posts)
|
||||||
|
|
||||||
paginated_archive_paths(
|
paginated_archive_paths(
|
||||||
route_language,
|
route_language,
|
||||||
["tag", archive_route_segment(tag)],
|
["tag", archive_route_segment(tag)],
|
||||||
length(posts),
|
post_count,
|
||||||
plan.max_posts_per_page
|
plan.max_posts_per_page
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
@@ -113,10 +119,12 @@ defmodule BDS.Generation.Outputs do
|
|||||||
if :date in plan.sections do
|
if :date in plan.sections do
|
||||||
year_paths =
|
year_paths =
|
||||||
Enum.flat_map(post_index.posts_by_year, fn {year, posts} ->
|
Enum.flat_map(post_index.posts_by_year, fn {year, posts} ->
|
||||||
|
post_count = length(posts)
|
||||||
|
|
||||||
paginated_archive_paths(
|
paginated_archive_paths(
|
||||||
route_language,
|
route_language,
|
||||||
[Integer.to_string(year)],
|
[Integer.to_string(year)],
|
||||||
length(posts),
|
post_count,
|
||||||
plan.max_posts_per_page
|
plan.max_posts_per_page
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
@@ -124,11 +132,12 @@ defmodule BDS.Generation.Outputs do
|
|||||||
month_paths =
|
month_paths =
|
||||||
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
|
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
|
||||||
[year, month] = String.split(year_month, "/", parts: 2)
|
[year, month] = String.split(year_month, "/", parts: 2)
|
||||||
|
post_count = length(posts)
|
||||||
|
|
||||||
paginated_archive_paths(
|
paginated_archive_paths(
|
||||||
route_language,
|
route_language,
|
||||||
[year, month],
|
[year, month],
|
||||||
length(posts),
|
post_count,
|
||||||
plan.max_posts_per_page
|
plan.max_posts_per_page
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
@@ -136,11 +145,12 @@ defmodule BDS.Generation.Outputs do
|
|||||||
day_paths =
|
day_paths =
|
||||||
Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} ->
|
Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} ->
|
||||||
[year, month, day] = String.split(year_month_day, "/", parts: 3)
|
[year, month, day] = String.split(year_month_day, "/", parts: 3)
|
||||||
|
post_count = length(posts)
|
||||||
|
|
||||||
paginated_archive_paths(
|
paginated_archive_paths(
|
||||||
route_language,
|
route_language,
|
||||||
[year, month, day],
|
[year, month, day],
|
||||||
length(posts),
|
post_count,
|
||||||
plan.max_posts_per_page
|
plan.max_posts_per_page
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
@@ -383,10 +393,12 @@ defmodule BDS.Generation.Outputs do
|
|||||||
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
|
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
|
||||||
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
|
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
|
||||||
|
|
||||||
|
effective_slug = effective_template_slug(project_id, post)
|
||||||
|
|
||||||
{page_output_path(post.slug, nil),
|
{page_output_path(post.slug, nil),
|
||||||
render_post_output(
|
render_post_output(
|
||||||
project_id,
|
project_id,
|
||||||
post.template_slug,
|
effective_slug,
|
||||||
%{
|
%{
|
||||||
id: canonical_variant.id,
|
id: canonical_variant.id,
|
||||||
title: canonical_variant.title,
|
title: canonical_variant.title,
|
||||||
@@ -415,20 +427,22 @@ defmodule BDS.Generation.Outputs do
|
|||||||
|> Enum.map(fn post ->
|
|> Enum.map(fn post ->
|
||||||
body = load_body(project_id, post.file_path, post.content)
|
body = load_body(project_id, post.file_path, post.content)
|
||||||
|
|
||||||
|
effective_slug = effective_template_slug(project_id, post)
|
||||||
|
|
||||||
{page_output_path(post.slug, language),
|
{page_output_path(post.slug, language),
|
||||||
render_post_output(
|
render_post_output(
|
||||||
project_id,
|
project_id,
|
||||||
post.template_slug,
|
effective_slug,
|
||||||
%{
|
%{
|
||||||
id: post.id,
|
id: post.id,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
content: body,
|
content: body,
|
||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
language: Map.get(post, :language),
|
language: post.language,
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt,
|
||||||
_post_record: post
|
_post_record: post
|
||||||
},
|
},
|
||||||
fn -> render_post_page(post.title, body, post.slug, Map.get(post, :language)) end
|
fn -> render_post_page(post.title, body, post.slug, post.language) end
|
||||||
)}
|
)}
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
@@ -513,10 +527,12 @@ defmodule BDS.Generation.Outputs do
|
|||||||
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
|
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
|
||||||
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
|
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
|
||||||
|
|
||||||
|
effective_slug = effective_template_slug(project_id, post)
|
||||||
|
|
||||||
{post_output_path(post),
|
{post_output_path(post),
|
||||||
render_post_output(
|
render_post_output(
|
||||||
project_id,
|
project_id,
|
||||||
post.template_slug,
|
effective_slug,
|
||||||
%{
|
%{
|
||||||
id: canonical_variant.id,
|
id: canonical_variant.id,
|
||||||
title: canonical_variant.title,
|
title: canonical_variant.title,
|
||||||
@@ -543,24 +559,40 @@ defmodule BDS.Generation.Outputs do
|
|||||||
Enum.map(posts, fn post ->
|
Enum.map(posts, fn post ->
|
||||||
body = load_body(project_id, post.file_path, post.content)
|
body = load_body(project_id, post.file_path, post.content)
|
||||||
|
|
||||||
|
effective_slug = effective_template_slug(project_id, post)
|
||||||
|
|
||||||
{post_output_path(post, language),
|
{post_output_path(post, language),
|
||||||
render_post_output(
|
render_post_output(
|
||||||
project_id,
|
project_id,
|
||||||
post.template_slug,
|
effective_slug,
|
||||||
%{
|
%{
|
||||||
id: post.id,
|
id: post.id,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
content: body,
|
content: body,
|
||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
language: Map.get(post, :language),
|
language: post.language,
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt,
|
||||||
_post_record: post
|
_post_record: post
|
||||||
},
|
},
|
||||||
fn -> render_post_page(post.title, body, post.slug, Map.get(post, :language)) end
|
fn -> render_post_page(post.title, body, post.slug, post.language) end
|
||||||
)}
|
)}
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
post_outputs ++ translation_outputs
|
post_outputs ++ translation_outputs
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp effective_template_slug(project_id, post) do
|
||||||
|
slug = Map.get(post, :template_slug)
|
||||||
|
|
||||||
|
if is_binary(slug) and slug != "" do
|
||||||
|
slug
|
||||||
|
else
|
||||||
|
TemplateSelection.resolve_post_template_slug(
|
||||||
|
project_id,
|
||||||
|
Map.get(post, :tags) || [],
|
||||||
|
Map.get(post, :categories) || []
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,10 +7,25 @@ defmodule BDS.Generation.Pagefind do
|
|||||||
@typedoc "A (relative_path, content) generated file tuple."
|
@typedoc "A (relative_path, content) generated file tuple."
|
||||||
@type generated_file :: {String.t(), String.t()}
|
@type generated_file :: {String.t(), String.t()}
|
||||||
|
|
||||||
|
@assets_dir Application.app_dir(:bds, "priv/preview_assets/assets")
|
||||||
|
@ui_js_path Path.join(@assets_dir, "pagefind-ui.js")
|
||||||
|
@ui_css_path Path.join(@assets_dir, "pagefind-ui.css")
|
||||||
|
|
||||||
|
@external_resource @ui_js_path
|
||||||
|
@external_resource @ui_css_path
|
||||||
|
|
||||||
|
@ui_js File.read!(@ui_js_path)
|
||||||
|
@ui_css File.read!(@ui_css_path)
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Build the per-language Pagefind index outputs (`pagefind/index.json`,
|
Build the per-language Pagefind index outputs (`pagefind/index.json`,
|
||||||
`pagefind/pagefind-ui.js`, `pagefind/pagefind-ui.css`) for every blog
|
`pagefind/pagefind-ui.js`, `pagefind/pagefind-ui.css`) for every blog
|
||||||
language declared on the plan.
|
language declared on the plan.
|
||||||
|
|
||||||
|
The fragment index records one entry per indexable page, where indexable
|
||||||
|
means the page carries a `data-pagefind-body` region. Each entry stores the
|
||||||
|
page URL, its title, and the body text scoped to that region — mirroring
|
||||||
|
Pagefind's behaviour of ignoring content outside `data-pagefind-body`.
|
||||||
"""
|
"""
|
||||||
@spec build_outputs(map(), [html_output()]) :: [generated_file()]
|
@spec build_outputs(map(), [html_output()]) :: [generated_file()]
|
||||||
def build_outputs(plan, html_outputs) do
|
def build_outputs(plan, html_outputs) do
|
||||||
@@ -31,8 +46,8 @@ defmodule BDS.Generation.Pagefind do
|
|||||||
[
|
[
|
||||||
{Path.join(prefix ++ ["index.json"]),
|
{Path.join(prefix ++ ["index.json"]),
|
||||||
Jason.encode!(%{"language" => language, "pages" => pages})},
|
Jason.encode!(%{"language" => language, "pages" => pages})},
|
||||||
{Path.join(prefix ++ ["pagefind-ui.js"]), ui_js(language)},
|
{Path.join(prefix ++ ["pagefind-ui.js"]), @ui_js},
|
||||||
{Path.join(prefix ++ ["pagefind-ui.css"]), ui_css()}
|
{Path.join(prefix ++ ["pagefind-ui.css"]), @ui_css}
|
||||||
]
|
]
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -43,11 +58,14 @@ defmodule BDS.Generation.Pagefind do
|
|||||||
String.ends_with?(relative_path, ".html") and
|
String.ends_with?(relative_path, ".html") and
|
||||||
language_match?(relative_path, route_language, other_prefixes)
|
language_match?(relative_path, route_language, other_prefixes)
|
||||||
end)
|
end)
|
||||||
|> Enum.map(fn {relative_path, content} ->
|
|> Enum.flat_map(fn {relative_path, content} ->
|
||||||
%{
|
case body_text(content) do
|
||||||
"url" => "/" <> relative_path,
|
nil ->
|
||||||
"text" => text(content)
|
[]
|
||||||
}
|
|
||||||
|
text ->
|
||||||
|
[%{"url" => "/" <> relative_path, "title" => title(content), "text" => text}]
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -60,19 +78,94 @@ defmodule BDS.Generation.Pagefind do
|
|||||||
defp language_match?(relative_path, route_language, _other_prefixes),
|
defp language_match?(relative_path, route_language, _other_prefixes),
|
||||||
do: String.starts_with?(relative_path, route_language <> "/")
|
do: String.starts_with?(relative_path, route_language <> "/")
|
||||||
|
|
||||||
defp text(content) do
|
# Extract the indexable body text scoped to the data-pagefind-body element.
|
||||||
content
|
# Returns nil when the page is not marked, so unmarked pages are excluded
|
||||||
|
# from the index entirely (matching Pagefind semantics).
|
||||||
|
defp body_text(content) do
|
||||||
|
case Regex.run(~r/<([a-zA-Z0-9]+)[^>]*\bdata-pagefind-body\b[^>]*>/, content,
|
||||||
|
return: :index
|
||||||
|
) do
|
||||||
|
[{open_start, open_len}, {tag_start, tag_len}] ->
|
||||||
|
tag = binary_part(content, tag_start, tag_len)
|
||||||
|
region = scoped_region(content, tag, open_start + open_len)
|
||||||
|
plain_text(region)
|
||||||
|
|
||||||
|
_no_match ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Capture the inner HTML of the marked element by balancing same-tag
|
||||||
|
# open/close pairs from the opening tag onward.
|
||||||
|
defp scoped_region(content, tag, body_start) do
|
||||||
|
rest = binary_part(content, body_start, byte_size(content) - body_start)
|
||||||
|
open_re = Regex.compile!("<#{tag}\\b", "i")
|
||||||
|
close_re = Regex.compile!("</#{tag}\\s*>", "i")
|
||||||
|
|
||||||
|
events =
|
||||||
|
(Regex.scan(open_re, rest, return: :index) ++ Regex.scan(close_re, rest, return: :index))
|
||||||
|
|> Enum.map(fn [{pos, _len}] -> pos end)
|
||||||
|
|> Enum.map(fn pos -> {pos, event_kind(rest, pos, tag)} end)
|
||||||
|
|> Enum.sort_by(&elem(&1, 0))
|
||||||
|
|
||||||
|
close_at = balanced_close(events, 0)
|
||||||
|
|
||||||
|
case close_at do
|
||||||
|
nil -> rest
|
||||||
|
pos -> binary_part(rest, 0, pos)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp event_kind(rest, pos, tag) do
|
||||||
|
if String.starts_with?(binary_part(rest, pos, min(2 + byte_size(tag), byte_size(rest) - pos)), "</") do
|
||||||
|
:close
|
||||||
|
else
|
||||||
|
:open
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp balanced_close([], _depth), do: nil
|
||||||
|
|
||||||
|
defp balanced_close([{pos, :close} | _rest], 0), do: pos
|
||||||
|
|
||||||
|
defp balanced_close([{_pos, :close} | rest], depth),
|
||||||
|
do: balanced_close(rest, depth - 1)
|
||||||
|
|
||||||
|
defp balanced_close([{_pos, :open} | rest], depth),
|
||||||
|
do: balanced_close(rest, depth + 1)
|
||||||
|
|
||||||
|
defp title(content) do
|
||||||
|
tag_text(content, ~r/<title[^>]*>(.*?)<\/title>/si) ||
|
||||||
|
tag_text(content, ~r/<h1[^>]*>(.*?)<\/h1>/si) ||
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tag_text(content, regex) do
|
||||||
|
case Regex.run(regex, content) do
|
||||||
|
[_full, raw] -> raw |> plain_text() |> nil_if_blank()
|
||||||
|
_no_match -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp nil_if_blank(""), do: nil
|
||||||
|
defp nil_if_blank(value), do: value
|
||||||
|
|
||||||
|
defp plain_text(html) do
|
||||||
|
html
|
||||||
|> String.replace(~r/<[^>]+>/, " ")
|
|> String.replace(~r/<[^>]+>/, " ")
|
||||||
|
|> decode_entities()
|
||||||
|> String.replace(~r/\s+/u, " ")
|
|> String.replace(~r/\s+/u, " ")
|
||||||
|> String.trim()
|
|> String.trim()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp ui_js(language) do
|
defp decode_entities(text) do
|
||||||
"window.bDSPagefind = { language: #{Jason.encode!(language)} };\n"
|
text
|
||||||
end
|
|> String.replace("&", "&")
|
||||||
|
|> String.replace("<", "<")
|
||||||
defp ui_css do
|
|> String.replace(">", ">")
|
||||||
".pagefind-ui{display:block;}\n"
|
|> String.replace(""", "\"")
|
||||||
|
|> String.replace("'", "'")
|
||||||
|
|> String.replace(" ", " ")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp route_language(main_language, language) when main_language == language, do: nil
|
defp route_language(main_language, language) when main_language == language, do: nil
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ defmodule BDS.Generation.Sitemap do
|
|||||||
page_path = Paths.relative_path_to_url_path(Paths.page_output_path(post.slug, nil))
|
page_path = Paths.relative_path_to_url_path(Paths.page_output_path(post.slug, nil))
|
||||||
|
|
||||||
languages =
|
languages =
|
||||||
if Paths.truthy_flag?(Map.get(post, :do_not_translate)),
|
if Paths.truthy_flag?(post.do_not_translate),
|
||||||
do: [plan.language],
|
do: [plan.language],
|
||||||
else: all_languages
|
else: all_languages
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ defmodule BDS.Generation.Validation do
|
|||||||
post_file_path:
|
post_file_path:
|
||||||
source_full_path(
|
source_full_path(
|
||||||
project_data_dir,
|
project_data_dir,
|
||||||
Map.get(post, :translation_file_path) || Map.get(post, :file_path)
|
Map.get(post, :translation_file_path) || post.file_path
|
||||||
),
|
),
|
||||||
generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0)
|
generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0)
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ defmodule BDS.Generation.Validation do
|
|||||||
|
|
||||||
%{
|
%{
|
||||||
post_url_path: relative_path_to_url_path(relative_path),
|
post_url_path: relative_path_to_url_path(relative_path),
|
||||||
post_file_path: source_full_path(project_data_dir, Map.get(post, :file_path)),
|
post_file_path: source_full_path(project_data_dir, post.file_path),
|
||||||
generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0)
|
generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0)
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -114,10 +114,19 @@ defmodule BDS.Git do
|
|||||||
def history(project_id, branch, opts \\ [])
|
def history(project_id, branch, opts \\ [])
|
||||||
when is_binary(project_id) and is_binary(branch) and is_list(opts) do
|
when is_binary(project_id) and is_binary(branch) and is_list(opts) do
|
||||||
with {:ok, project_dir} <- project_dir(project_id),
|
with {:ok, project_dir} <- project_dir(project_id),
|
||||||
{:ok, local_log} <- run_git(project_dir, ["log", "--format=%H%x09%s", branch], opts),
|
{:ok, local_log} <-
|
||||||
{:ok, remote_log} <-
|
run_git(
|
||||||
run_git(project_dir, ["log", "--format=%H", "origin/#{branch}"], opts) do
|
project_dir,
|
||||||
local_commits = parse_local_history(local_log)
|
["log", "--date=short", "--format=%H%x09%an%x09%ad%x09%s", branch],
|
||||||
|
opts
|
||||||
|
) do
|
||||||
|
remote_log =
|
||||||
|
case run_git(project_dir, ["log", "--format=%H", "origin/#{branch}"], opts) do
|
||||||
|
{:ok, output} -> output
|
||||||
|
{:error, {:git_failed, _message}} -> ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local_commits = parse_history_log(local_log)
|
||||||
remote_hashes = MapSet.new(parse_remote_history(remote_log))
|
remote_hashes = MapSet.new(parse_remote_history(remote_log))
|
||||||
local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash))
|
local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash))
|
||||||
|
|
||||||
@@ -126,7 +135,7 @@ defmodule BDS.Git do
|
|||||||
|> MapSet.difference(local_hashes)
|
|> MapSet.difference(local_hashes)
|
||||||
|> MapSet.to_list()
|
|> MapSet.to_list()
|
||||||
|> Enum.map(fn hash ->
|
|> Enum.map(fn hash ->
|
||||||
%{hash: hash, subject: nil, sync_status: %{kind: :remote_only}}
|
%{hash: hash, subject: nil, author: nil, date: nil, sync_status: %{kind: :remote_only}}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
commits =
|
commits =
|
||||||
@@ -204,6 +213,22 @@ defmodule BDS.Git do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_remote(project_id, remote_url, opts \\ [])
|
||||||
|
when is_binary(project_id) and is_binary(remote_url) and is_list(opts) do
|
||||||
|
with {:ok, project_dir} <- project_dir(project_id) do
|
||||||
|
case run_git(project_dir, ["remote", "add", "origin", remote_url], opts) do
|
||||||
|
{:ok, _output} ->
|
||||||
|
{:ok, %{remote_url: remote_url}}
|
||||||
|
|
||||||
|
{:error, {:git_failed, _message}} ->
|
||||||
|
with {:ok, _output} <-
|
||||||
|
run_git(project_dir, ["remote", "set-url", "origin", remote_url], opts) do
|
||||||
|
{:ok, %{remote_url: remote_url}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def remote_state(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
|
def remote_state(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
|
||||||
with {:ok, project_dir} <- project_dir(project_id),
|
with {:ok, project_dir} <- project_dir(project_id),
|
||||||
{:ok, local_branch} <- current_branch(project_dir, opts) do
|
{:ok, local_branch} <- current_branch(project_dir, opts) do
|
||||||
@@ -380,6 +405,23 @@ defmodule BDS.Git do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp parse_history_log(output) do
|
||||||
|
output
|
||||||
|
|> String.split("\n", trim: true)
|
||||||
|
|> Enum.map(fn line ->
|
||||||
|
case String.split(line, "\t", parts: 4) do
|
||||||
|
[hash, author, date, subject] ->
|
||||||
|
%{hash: hash, author: author, date: date, subject: subject}
|
||||||
|
|
||||||
|
[hash, author, date] ->
|
||||||
|
%{hash: hash, author: author, date: date, subject: nil}
|
||||||
|
|
||||||
|
[hash | _rest] ->
|
||||||
|
%{hash: hash, author: nil, date: nil, subject: nil}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp parse_remote_history(output) do
|
defp parse_remote_history(output) do
|
||||||
String.split(output, "\n", trim: true)
|
String.split(output, "\n", trim: true)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -605,6 +605,6 @@ defmodule BDS.ImportExecution do
|
|||||||
|
|
||||||
defp project_default_author(project_id) do
|
defp project_default_author(project_id) do
|
||||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
Map.get(metadata, :default_author)
|
metadata.default_author
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -101,11 +101,18 @@ defmodule BDS.Maintenance.Repair do
|
|||||||
:file_to_db ->
|
:file_to_db ->
|
||||||
post_ids = Enum.map(items, &metadata_diff_item_entity_id/1)
|
post_ids = Enum.map(items, &metadata_diff_item_entity_id/1)
|
||||||
|
|
||||||
{:ok, repaired_post_ids} = Embeddings.repair_posts(project_id, post_ids)
|
# If the embedding model is unavailable, every item is reported as
|
||||||
repaired_post_ids = MapSet.new(repaired_post_ids)
|
# failed rather than crashing the repair task.
|
||||||
|
repaired =
|
||||||
|
case Embeddings.repair_posts(project_id, post_ids) do
|
||||||
|
{:ok, repaired_post_ids} -> repaired_post_ids
|
||||||
|
{:error, _reason} -> []
|
||||||
|
end
|
||||||
|
|
||||||
|
repaired_set = MapSet.new(repaired)
|
||||||
|
|
||||||
build_batch_repair_result(items, total, on_progress, fn item ->
|
build_batch_repair_result(items, total, on_progress, fn item ->
|
||||||
MapSet.member?(repaired_post_ids, metadata_diff_item_entity_id(item))
|
MapSet.member?(repaired_set, metadata_diff_item_entity_id(item))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
:db_to_file ->
|
:db_to_file ->
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ defmodule BDS.Media do
|
|||||||
|> Repo.insert!()
|
|> Repo.insert!()
|
||||||
end) do
|
end) do
|
||||||
{:ok, media} ->
|
{:ok, media} ->
|
||||||
:ok = write_sidecar(project, media)
|
log_sidecar_error(write_sidecar(project, media), media.id)
|
||||||
log_thumbnail_error(ensure_thumbnails(project, media), media.id)
|
log_thumbnail_error(ensure_thumbnails(project, media), media.id)
|
||||||
:ok = Search.sync_media(media)
|
:ok = Search.sync_media(media)
|
||||||
{:ok, media}
|
{:ok, media}
|
||||||
@@ -148,7 +148,7 @@ defmodule BDS.Media do
|
|||||||
|> Repo.update!()
|
|> Repo.update!()
|
||||||
end) do
|
end) do
|
||||||
{:ok, updated_media} ->
|
{:ok, updated_media} ->
|
||||||
:ok = write_sidecar(project, updated_media)
|
log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
|
||||||
:ok = Search.sync_media(updated_media)
|
:ok = Search.sync_media(updated_media)
|
||||||
{:ok, updated_media}
|
{:ok, updated_media}
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ defmodule BDS.Media do
|
|||||||
|> Repo.insert_or_update!()
|
|> Repo.insert_or_update!()
|
||||||
end) do
|
end) do
|
||||||
{:ok, updated_translation} ->
|
{:ok, updated_translation} ->
|
||||||
:ok = write_translation_sidecar(project, media, updated_translation)
|
log_sidecar_error(write_translation_sidecar(project, media, updated_translation), media.id)
|
||||||
:ok = Search.sync_media(media.id)
|
:ok = Search.sync_media(media.id)
|
||||||
{:ok, updated_translation}
|
{:ok, updated_translation}
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ defmodule BDS.Media do
|
|||||||
)
|
)
|
||||||
|
|
||||||
:ok = Search.sync_media(media)
|
:ok = Search.sync_media(media)
|
||||||
:ok = write_sidecar(project, media)
|
log_sidecar_error(write_sidecar(project, media), media.id)
|
||||||
{:ok, true}
|
{:ok, true}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
@@ -322,7 +322,7 @@ defmodule BDS.Media do
|
|||||||
end) do
|
end) do
|
||||||
{:ok, updated_media} ->
|
{:ok, updated_media} ->
|
||||||
_ = File.rm(previous_destination_backup)
|
_ = File.rm(previous_destination_backup)
|
||||||
:ok = write_sidecar(project, updated_media)
|
log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
|
||||||
log_thumbnail_error(ensure_thumbnails(project, updated_media), updated_media.id)
|
log_thumbnail_error(ensure_thumbnails(project, updated_media), updated_media.id)
|
||||||
:ok = Search.sync_media(updated_media)
|
:ok = Search.sync_media(updated_media)
|
||||||
{:ok, updated_media}
|
{:ok, updated_media}
|
||||||
@@ -350,4 +350,10 @@ defmodule BDS.Media do
|
|||||||
defp log_thumbnail_error({:error, reason}, media_id) do
|
defp log_thumbnail_error({:error, reason}, media_id) do
|
||||||
Logger.warning("Thumbnail generation failed for media #{media_id}: #{inspect(reason)}")
|
Logger.warning("Thumbnail generation failed for media #{media_id}: #{inspect(reason)}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp log_sidecar_error(:ok, _media_id), do: :ok
|
||||||
|
|
||||||
|
defp log_sidecar_error({:error, reason}, media_id) do
|
||||||
|
Logger.warning("Sidecar write failed for media #{media_id}: #{inspect(reason)}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
defmodule BDS.Media.Linking do
|
defmodule BDS.Media.Linking do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
@@ -64,7 +66,7 @@ defmodule BDS.Media.Linking do
|
|||||||
end
|
end
|
||||||
end) do
|
end) do
|
||||||
{:ok, _result} ->
|
{:ok, _result} ->
|
||||||
:ok = Sidecars.write_sidecar(project, media)
|
log_sidecar_error(Sidecars.write_sidecar(project, media), media.id)
|
||||||
{:ok, :linked}
|
{:ok, :linked}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
@@ -93,7 +95,7 @@ defmodule BDS.Media.Linking do
|
|||||||
:ok
|
:ok
|
||||||
end) do
|
end) do
|
||||||
{:ok, :ok} ->
|
{:ok, :ok} ->
|
||||||
:ok = Sidecars.write_sidecar(project, media)
|
log_sidecar_error(Sidecars.write_sidecar(project, media), media.id)
|
||||||
{:ok, :unlinked}
|
{:ok, :unlinked}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
@@ -112,6 +114,12 @@ defmodule BDS.Media.Linking do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp log_sidecar_error(:ok, _media_id), do: :ok
|
||||||
|
|
||||||
|
defp log_sidecar_error({:error, reason}, media_id) do
|
||||||
|
Logger.warning("Sidecar write failed for media #{media_id}: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
|
||||||
defp next_sort_order(media_id) do
|
defp next_sort_order(media_id) do
|
||||||
case Repo.one(
|
case Repo.one(
|
||||||
from pm in PostMedia,
|
from pm in PostMedia,
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ defmodule BDS.Media.Sidecars do
|
|||||||
alias BDS.Search
|
alias BDS.Search
|
||||||
alias BDS.Sidecar
|
alias BDS.Sidecar
|
||||||
|
|
||||||
@spec write_sidecar(BDS.Projects.Project.t(), Media.t()) :: :ok
|
@spec write_sidecar(BDS.Projects.Project.t(), Media.t()) :: :ok | {:error, File.posix()}
|
||||||
def write_sidecar(project, media) do
|
def write_sidecar(project, media) do
|
||||||
path = Path.join(Projects.project_data_dir(project), media.sidecar_path)
|
path = Path.join(Projects.project_data_dir(project), media.sidecar_path)
|
||||||
:ok = File.mkdir_p(Path.dirname(path))
|
|
||||||
|
|
||||||
atomic_write(
|
atomic_write(
|
||||||
path,
|
path,
|
||||||
@@ -45,7 +44,8 @@ defmodule BDS.Media.Sidecars do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec write_translation_sidecar(BDS.Projects.Project.t(), Media.t(), Translation.t()) :: :ok
|
@spec write_translation_sidecar(BDS.Projects.Project.t(), Media.t(), Translation.t()) ::
|
||||||
|
:ok | {:error, File.posix()}
|
||||||
def write_translation_sidecar(project, media, translation) do
|
def write_translation_sidecar(project, media, translation) do
|
||||||
path =
|
path =
|
||||||
Path.join(
|
Path.join(
|
||||||
@@ -53,8 +53,6 @@ defmodule BDS.Media.Sidecars do
|
|||||||
translation_sidecar_path(media, translation.language)
|
translation_sidecar_path(media, translation.language)
|
||||||
)
|
)
|
||||||
|
|
||||||
:ok = File.mkdir_p(Path.dirname(path))
|
|
||||||
|
|
||||||
atomic_write(
|
atomic_write(
|
||||||
path,
|
path,
|
||||||
Sidecar.serialize_document([
|
Sidecar.serialize_document([
|
||||||
@@ -189,8 +187,7 @@ defmodule BDS.Media.Sidecars do
|
|||||||
|
|
||||||
media ->
|
media ->
|
||||||
project = Projects.get_project!(media.project_id)
|
project = Projects.get_project!(media.project_id)
|
||||||
:ok = write_sidecar(project, media)
|
write_sidecar(project, media)
|
||||||
:ok
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -224,8 +221,11 @@ defmodule BDS.Media.Sidecars do
|
|||||||
%Translation{} = translation ->
|
%Translation{} = translation ->
|
||||||
media = Repo.get!(Media, translation.translation_for)
|
media = Repo.get!(Media, translation.translation_for)
|
||||||
project = Projects.get_project!(media.project_id)
|
project = Projects.get_project!(media.project_id)
|
||||||
:ok = write_translation_sidecar(project, media, translation)
|
|
||||||
{:ok, translation}
|
case write_translation_sidecar(project, media, translation) do
|
||||||
|
:ok -> {:ok, translation}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
defmodule BDS.Metadata do
|
defmodule BDS.Metadata do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias BDS.Embeddings
|
alias BDS.Embeddings
|
||||||
alias BDS.I18n
|
alias BDS.I18n
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
@@ -13,6 +15,9 @@ defmodule BDS.Metadata do
|
|||||||
@default_categories ["article", "aside", "page", "picture"]
|
@default_categories ["article", "aside", "page", "picture"]
|
||||||
@min_posts_per_page 1
|
@min_posts_per_page 1
|
||||||
@max_posts_per_page 500
|
@max_posts_per_page 500
|
||||||
|
@default_image_import_concurrency 4
|
||||||
|
@min_image_import_concurrency 1
|
||||||
|
@max_image_import_concurrency 8
|
||||||
@supported_pico_themes MapSet.new([
|
@supported_pico_themes MapSet.new([
|
||||||
"default",
|
"default",
|
||||||
"amber",
|
"amber",
|
||||||
@@ -70,6 +75,7 @@ defmodule BDS.Metadata do
|
|||||||
:main_language,
|
:main_language,
|
||||||
:default_author,
|
:default_author,
|
||||||
:max_posts_per_page,
|
:max_posts_per_page,
|
||||||
|
:image_import_concurrency,
|
||||||
:blogmark_category,
|
:blogmark_category,
|
||||||
:pico_theme,
|
:pico_theme,
|
||||||
:semantic_similarity_enabled,
|
:semantic_similarity_enabled,
|
||||||
@@ -238,6 +244,8 @@ defmodule BDS.Metadata do
|
|||||||
default_author: Map.get(project_metadata, "default_author"),
|
default_author: Map.get(project_metadata, "default_author"),
|
||||||
max_posts_per_page:
|
max_posts_per_page:
|
||||||
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
|
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
|
||||||
|
image_import_concurrency:
|
||||||
|
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
|
||||||
blogmark_category: Map.get(project_metadata, "blogmark_category"),
|
blogmark_category: Map.get(project_metadata, "blogmark_category"),
|
||||||
pico_theme: Map.get(project_metadata, "pico_theme"),
|
pico_theme: Map.get(project_metadata, "pico_theme"),
|
||||||
semantic_similarity_enabled:
|
semantic_similarity_enabled:
|
||||||
@@ -274,6 +282,8 @@ defmodule BDS.Metadata do
|
|||||||
default_author: Map.get(project_metadata, "default_author"),
|
default_author: Map.get(project_metadata, "default_author"),
|
||||||
max_posts_per_page:
|
max_posts_per_page:
|
||||||
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
|
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
|
||||||
|
image_import_concurrency:
|
||||||
|
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
|
||||||
blogmark_category: Map.get(project_metadata, "blogmark_category"),
|
blogmark_category: Map.get(project_metadata, "blogmark_category"),
|
||||||
pico_theme: Map.get(project_metadata, "pico_theme"),
|
pico_theme: Map.get(project_metadata, "pico_theme"),
|
||||||
semantic_similarity_enabled:
|
semantic_similarity_enabled:
|
||||||
@@ -293,6 +303,7 @@ defmodule BDS.Metadata do
|
|||||||
main_language: nil,
|
main_language: nil,
|
||||||
default_author: nil,
|
default_author: nil,
|
||||||
max_posts_per_page: @default_max_posts_per_page,
|
max_posts_per_page: @default_max_posts_per_page,
|
||||||
|
image_import_concurrency: @default_image_import_concurrency,
|
||||||
blogmark_category: nil,
|
blogmark_category: nil,
|
||||||
pico_theme: nil,
|
pico_theme: nil,
|
||||||
semantic_similarity_enabled: false,
|
semantic_similarity_enabled: false,
|
||||||
@@ -308,6 +319,8 @@ defmodule BDS.Metadata do
|
|||||||
main_language: normalize_optional_language(attr(attrs, :main_language)),
|
main_language: normalize_optional_language(attr(attrs, :main_language)),
|
||||||
default_author: attr(attrs, :default_author),
|
default_author: attr(attrs, :default_author),
|
||||||
max_posts_per_page: normalize_posts_per_page(attr(attrs, :max_posts_per_page)),
|
max_posts_per_page: normalize_posts_per_page(attr(attrs, :max_posts_per_page)),
|
||||||
|
image_import_concurrency:
|
||||||
|
normalize_image_import_concurrency(attr(attrs, :image_import_concurrency)),
|
||||||
blogmark_category: attr(attrs, :blogmark_category),
|
blogmark_category: attr(attrs, :blogmark_category),
|
||||||
pico_theme: normalize_pico_theme(attr(attrs, :pico_theme)),
|
pico_theme: normalize_pico_theme(attr(attrs, :pico_theme)),
|
||||||
semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false,
|
semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false,
|
||||||
@@ -342,6 +355,7 @@ defmodule BDS.Metadata do
|
|||||||
"main_language" => project_metadata.main_language,
|
"main_language" => project_metadata.main_language,
|
||||||
"default_author" => project_metadata.default_author,
|
"default_author" => project_metadata.default_author,
|
||||||
"max_posts_per_page" => project_metadata.max_posts_per_page,
|
"max_posts_per_page" => project_metadata.max_posts_per_page,
|
||||||
|
"image_import_concurrency" => project_metadata.image_import_concurrency,
|
||||||
"blogmark_category" => project_metadata.blogmark_category,
|
"blogmark_category" => project_metadata.blogmark_category,
|
||||||
"pico_theme" => project_metadata.pico_theme,
|
"pico_theme" => project_metadata.pico_theme,
|
||||||
"semantic_similarity_enabled" => project_metadata.semantic_similarity_enabled,
|
"semantic_similarity_enabled" => project_metadata.semantic_similarity_enabled,
|
||||||
@@ -429,6 +443,8 @@ defmodule BDS.Metadata do
|
|||||||
"main_language" => Map.get(payload, "mainLanguage"),
|
"main_language" => Map.get(payload, "mainLanguage"),
|
||||||
"default_author" => Map.get(payload, "defaultAuthor"),
|
"default_author" => Map.get(payload, "defaultAuthor"),
|
||||||
"max_posts_per_page" => Map.get(payload, "maxPostsPerPage", @default_max_posts_per_page),
|
"max_posts_per_page" => Map.get(payload, "maxPostsPerPage", @default_max_posts_per_page),
|
||||||
|
"image_import_concurrency" =>
|
||||||
|
Map.get(payload, "imageImportConcurrency", @default_image_import_concurrency),
|
||||||
"blogmark_category" => Map.get(payload, "blogmarkCategory"),
|
"blogmark_category" => Map.get(payload, "blogmarkCategory"),
|
||||||
"pico_theme" => Map.get(payload, "picoTheme"),
|
"pico_theme" => Map.get(payload, "picoTheme"),
|
||||||
"semantic_similarity_enabled" => Map.get(payload, "semanticSimilarityEnabled", false),
|
"semantic_similarity_enabled" => Map.get(payload, "semanticSimilarityEnabled", false),
|
||||||
@@ -505,6 +521,8 @@ defmodule BDS.Metadata do
|
|||||||
"defaultAuthor" => Map.get(project_metadata, "default_author"),
|
"defaultAuthor" => Map.get(project_metadata, "default_author"),
|
||||||
"maxPostsPerPage" =>
|
"maxPostsPerPage" =>
|
||||||
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
|
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
|
||||||
|
"imageImportConcurrency" =>
|
||||||
|
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
|
||||||
"blogmarkCategory" => Map.get(project_metadata, "blogmark_category"),
|
"blogmarkCategory" => Map.get(project_metadata, "blogmark_category"),
|
||||||
"picoTheme" => Map.get(project_metadata, "pico_theme"),
|
"picoTheme" => Map.get(project_metadata, "pico_theme"),
|
||||||
"semanticSimilarityEnabled" =>
|
"semanticSimilarityEnabled" =>
|
||||||
@@ -576,6 +594,23 @@ defmodule BDS.Metadata do
|
|||||||
|
|
||||||
defp normalize_posts_per_page(_value), do: @default_max_posts_per_page
|
defp normalize_posts_per_page(_value), do: @default_max_posts_per_page
|
||||||
|
|
||||||
|
defp normalize_image_import_concurrency(nil), do: @default_image_import_concurrency
|
||||||
|
|
||||||
|
defp normalize_image_import_concurrency(value) when is_integer(value) do
|
||||||
|
value
|
||||||
|
|> max(@min_image_import_concurrency)
|
||||||
|
|> min(@max_image_import_concurrency)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_image_import_concurrency(value) when is_binary(value) do
|
||||||
|
case Integer.parse(String.trim(value)) do
|
||||||
|
{integer, ""} -> normalize_image_import_concurrency(integer)
|
||||||
|
_ -> @default_image_import_concurrency
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_image_import_concurrency(_value), do: @default_image_import_concurrency
|
||||||
|
|
||||||
defp normalize_optional_language(nil), do: nil
|
defp normalize_optional_language(nil), do: nil
|
||||||
defp normalize_optional_language(""), do: nil
|
defp normalize_optional_language(""), do: nil
|
||||||
|
|
||||||
@@ -620,7 +655,17 @@ defmodule BDS.Metadata do
|
|||||||
) do
|
) do
|
||||||
if previous_state.semantic_similarity_enabled != true and
|
if previous_state.semantic_similarity_enabled != true and
|
||||||
project_metadata.semantic_similarity_enabled == true do
|
project_metadata.semantic_similarity_enabled == true do
|
||||||
{:ok, _indexed_post_ids} = Embeddings.index_unindexed(project_id)
|
# Backfill is best-effort: if the embedding model is unavailable, keep the
|
||||||
|
# setting enabled and log it rather than failing the metadata update.
|
||||||
|
case Embeddings.index_unindexed(project_id) do
|
||||||
|
{:ok, _indexed_post_ids} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"Embedding backfill skipped for project #{project_id}: #{inspect(reason)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ defmodule BDS.Posts do
|
|||||||
serialize_post_file(%{post | updated_at: updated_at, content: body}, published_at)
|
serialize_post_file(%{post | updated_at: updated_at, content: body}, published_at)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if post.file_path != "" and post.file_path != relative_path do
|
||||||
|
delete_post_file(post)
|
||||||
|
end
|
||||||
|
|
||||||
post
|
post
|
||||||
|> Post.changeset(%{
|
|> Post.changeset(%{
|
||||||
status: :published,
|
status: :published,
|
||||||
@@ -309,8 +313,11 @@ defmodule BDS.Posts do
|
|||||||
select: pm.media_id
|
select: pm.media_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
{:ok, translations} = Translations.list_post_translations(post.id)
|
||||||
|
|
||||||
case Repo.delete(post) do
|
case Repo.delete(post) do
|
||||||
{:ok, deleted_post} ->
|
{:ok, deleted_post} ->
|
||||||
|
Enum.each(translations, &FileSync.delete_translation_file/1)
|
||||||
delete_post_file(deleted_post)
|
delete_post_file(deleted_post)
|
||||||
Embeddings.remove_post(deleted_post.id)
|
Embeddings.remove_post(deleted_post.id)
|
||||||
PostLinks.delete_post_links(deleted_post.id)
|
PostLinks.delete_post_links(deleted_post.id)
|
||||||
@@ -352,6 +359,36 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec unarchive_post(String.t()) ::
|
||||||
|
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
|
def unarchive_post(post_id) do
|
||||||
|
case Repo.get(Post, post_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%Post{status: :archived} = post ->
|
||||||
|
content = restore_content_for_unarchive(post)
|
||||||
|
|
||||||
|
post
|
||||||
|
|> Post.changeset(%{status: :draft, content: content, updated_at: Persistence.now_ms()})
|
||||||
|
|> Repo.update()
|
||||||
|
|> case do
|
||||||
|
{:ok, updated_post} ->
|
||||||
|
:ok = Search.sync_post(updated_post)
|
||||||
|
{:ok, updated_post}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
|
||||||
|
%Post{} = post ->
|
||||||
|
{:error,
|
||||||
|
post
|
||||||
|
|> Post.changeset(%{})
|
||||||
|
|> Ecto.Changeset.add_error(:status, "cannot unarchive non-archived post")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@spec get_post!(String.t()) :: Post.t()
|
@spec get_post!(String.t()) :: Post.t()
|
||||||
@spec get_post(String.t()) :: Post.t() | nil
|
@spec get_post(String.t()) :: Post.t() | nil
|
||||||
def get_post(post_id), do: Repo.get(Post, post_id)
|
def get_post(post_id), do: Repo.get(Post, post_id)
|
||||||
@@ -581,6 +618,17 @@ defmodule BDS.Posts do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp restore_content_for_unarchive(%Post{content: content}) when is_binary(content), do: content
|
||||||
|
|
||||||
|
defp restore_content_for_unarchive(%Post{file_path: file_path} = post)
|
||||||
|
when file_path not in [nil, ""] do
|
||||||
|
project = Projects.get_project!(post.project_id)
|
||||||
|
full_path = Path.join(Projects.project_data_dir(project), file_path)
|
||||||
|
read_markdown_body(full_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp restore_content_for_unarchive(_post), do: ""
|
||||||
|
|
||||||
defp normalize_title(nil), do: ""
|
defp normalize_title(nil), do: ""
|
||||||
defp normalize_title(title), do: title
|
defp normalize_title(title), do: title
|
||||||
|
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ defmodule BDS.Posts.AutoTranslation do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp configured_languages(metadata) do
|
defp configured_languages(metadata) do
|
||||||
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, []))
|
([metadata.main_language] ++ metadata.blog_languages)
|
||||||
|> Enum.map(&normalize_language/1)
|
|> Enum.map(&normalize_language/1)
|
||||||
|> Enum.reject(&(&1 in [nil, ""]))
|
|> Enum.reject(&(&1 in [nil, ""]))
|
||||||
|> Enum.uniq()
|
|> Enum.uniq()
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ defmodule BDS.Posts.FileSync do
|
|||||||
{"status", :published},
|
{"status", :published},
|
||||||
{"author", post.author},
|
{"author", post.author},
|
||||||
{"language", post.language},
|
{"language", post.language},
|
||||||
{"doNotTranslate", post.do_not_translate},
|
{"doNotTranslate", post.do_not_translate || nil},
|
||||||
{"templateSlug", post.template_slug},
|
{"templateSlug", post.template_slug},
|
||||||
{"createdAt", post.created_at},
|
{"createdAt", post.created_at},
|
||||||
{"updatedAt", post.updated_at},
|
{"updatedAt", post.updated_at},
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ defmodule BDS.Posts.TranslationValidation do
|
|||||||
|
|
||||||
defp legacy_missing_entries(source_posts, translation_rows, metadata) do
|
defp legacy_missing_entries(source_posts, translation_rows, metadata) do
|
||||||
configured_languages =
|
configured_languages =
|
||||||
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, []))
|
([metadata.main_language] ++ metadata.blog_languages)
|
||||||
|> Enum.map(&do_normalize_language/1)
|
|> Enum.map(&do_normalize_language/1)
|
||||||
|> Enum.reject(&(&1 in [nil, ""]))
|
|> Enum.reject(&(&1 in [nil, ""]))
|
||||||
|> Enum.uniq()
|
|> Enum.uniq()
|
||||||
@@ -444,7 +444,7 @@ defmodule BDS.Posts.TranslationValidation do
|
|||||||
language = do_normalize_language(source_post.language)
|
language = do_normalize_language(source_post.language)
|
||||||
|
|
||||||
if language == "" do
|
if language == "" do
|
||||||
do_normalize_language(Map.get(metadata, :main_language))
|
do_normalize_language(metadata.main_language)
|
||||||
else
|
else
|
||||||
language
|
language
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ defmodule BDS.Preview do
|
|||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Rendering
|
alias BDS.Rendering
|
||||||
|
alias BDS.Rendering.TemplateSelection
|
||||||
|
|
||||||
@host "127.0.0.1"
|
@host "127.0.0.1"
|
||||||
@port 4123
|
@port 4123
|
||||||
|
|
||||||
|
# Max time to wait for inflight requests to finish during graceful shutdown
|
||||||
|
# before remaining request tasks are forcibly terminated.
|
||||||
|
@drain_timeout 5_000
|
||||||
|
|
||||||
def start_link(_opts) do
|
def start_link(_opts) do
|
||||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||||
end
|
end
|
||||||
@@ -55,7 +60,7 @@ defmodule BDS.Preview do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(_state) do
|
def init(_state) do
|
||||||
{:ok, %{current: nil}}
|
{:ok, %{current: nil, stopping: nil}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -77,15 +82,12 @@ defmodule BDS.Preview do
|
|||||||
{:reply, reply, next_state}
|
{:reply, reply, next_state}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_call({:stop_preview, project_id}, _from, state) do
|
def handle_call({:stop_preview, project_id}, from, state) do
|
||||||
next_state =
|
|
||||||
if match?(%{project_id: ^project_id}, state.current) do
|
if match?(%{project_id: ^project_id}, state.current) do
|
||||||
stop_current_server(state)
|
begin_graceful_stop(state, from)
|
||||||
else
|
else
|
||||||
state
|
{:reply, :ok, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
{:reply, :ok, next_state}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_call({:request, project_id, request_path, query_params}, _from, state) do
|
def handle_call({:request, project_id, request_path, query_params}, _from, state) do
|
||||||
@@ -101,7 +103,7 @@ defmodule BDS.Preview do
|
|||||||
with :ok <- ensure_running(state.current, project_id),
|
with :ok <- ensure_running(state.current, project_id),
|
||||||
{:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
|
{:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
|
||||||
body =
|
body =
|
||||||
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do
|
case Rendering.render_post_page(project_id, payload.template_slug, payload) do
|
||||||
{:ok, rendered} -> rendered
|
{:ok, rendered} -> rendered
|
||||||
{:error, _reason} -> render_draft(payload)
|
{:error, _reason} -> render_draft(payload)
|
||||||
end
|
end
|
||||||
@@ -140,6 +142,25 @@ defmodule BDS.Preview do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
def handle_cast({:track_request, pid}, %{current: %{} = current} = state) when is_pid(pid) do
|
||||||
|
ref = Process.monitor(pid)
|
||||||
|
inflight = Map.put(current.inflight, ref, pid)
|
||||||
|
{:noreply, %{state | current: %{current | inflight: inflight}}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_cast({:track_request, _pid}, state), do: {:noreply, state}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:DOWN, ref, :process, _pid, _reason}, %{current: %{} = current} = state) do
|
||||||
|
inflight = Map.delete(current.inflight, ref)
|
||||||
|
state = %{state | current: %{current | inflight: inflight}}
|
||||||
|
{:noreply, maybe_finalize_stop(state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(:drain_timeout, state) do
|
||||||
|
{:noreply, force_finalize_stop(state)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info(_msg, state) do
|
def handle_info(_msg, state) do
|
||||||
{:noreply, state}
|
{:noreply, state}
|
||||||
end
|
end
|
||||||
@@ -154,31 +175,46 @@ defmodule BDS.Preview do
|
|||||||
|
|
||||||
:error ->
|
:error ->
|
||||||
with {:ok, relative_path, kind} <- route_request(request_path) do
|
with {:ok, relative_path, kind} <- route_request(request_path) do
|
||||||
full_path =
|
|
||||||
case kind do
|
case kind do
|
||||||
:media -> safe_join(server.data_dir, Path.join(["media", relative_path]))
|
:media ->
|
||||||
:generated -> safe_join(Path.join(server.data_dir, "html"), relative_path)
|
serve_file(safe_join(server.data_dir, Path.join(["media", relative_path])),
|
||||||
|
server: server, query_params: query_params)
|
||||||
|
|
||||||
|
:generated ->
|
||||||
|
case BDS.Preview.Router.render_route(server.project_id, request_path) do
|
||||||
|
{:ok, response} ->
|
||||||
|
{:ok, apply_response_overrides(response, query_params)}
|
||||||
|
|
||||||
|
:not_matched ->
|
||||||
|
serve_file(safe_join(Path.join(server.data_dir, "html"), relative_path),
|
||||||
|
server: server, query_params: query_params)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
case full_path do
|
defp serve_file({:error, :not_found}, opts) do
|
||||||
{:error, :not_found} ->
|
render_not_found_response(opts[:server].project_id, opts[:query_params])
|
||||||
{:error, :not_found}
|
end
|
||||||
|
|
||||||
resolved_path ->
|
defp serve_file(resolved_path, opts) do
|
||||||
case read_response(resolved_path) do
|
case read_response(resolved_path) do
|
||||||
{:error, :not_found} -> render_not_found_response(server.project_id, query_params)
|
{:error, :not_found} ->
|
||||||
{:ok, response} -> {:ok, apply_response_overrides(response, query_params)}
|
render_not_found_response(opts[:server].project_id, opts[:query_params])
|
||||||
other -> other
|
|
||||||
end
|
{:ok, response} ->
|
||||||
end
|
{:ok, apply_response_overrides(response, opts[:query_params])}
|
||||||
end
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp resolve_draft_request(project_id, post_id, query_params) do
|
defp resolve_draft_request(project_id, post_id, query_params) do
|
||||||
with {:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
|
with {:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
|
||||||
body =
|
body =
|
||||||
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do
|
case Rendering.render_post_page(project_id, payload.template_slug, payload) do
|
||||||
{:ok, rendered} -> rendered
|
{:ok, rendered} -> rendered
|
||||||
{:error, _reason} -> render_draft(payload)
|
{:error, _reason} -> render_draft(payload)
|
||||||
end
|
end
|
||||||
@@ -204,6 +240,7 @@ defmodule BDS.Preview do
|
|||||||
|
|
||||||
defp draft_preview_payload(post, query_params) do
|
defp draft_preview_payload(post, query_params) do
|
||||||
requested_language = query_params |> Map.get("lang") |> normalize_requested_language()
|
requested_language = query_params |> Map.get("lang") |> normalize_requested_language()
|
||||||
|
effective_slug = post.template_slug || TemplateSelection.resolve_post_template_slug(post.project_id, post.tags, post.categories)
|
||||||
|
|
||||||
case draft_preview_translation(post.id, requested_language, post.language) do
|
case draft_preview_translation(post.id, requested_language, post.language) do
|
||||||
%Translation{} = translation ->
|
%Translation{} = translation ->
|
||||||
@@ -215,7 +252,7 @@ defmodule BDS.Preview do
|
|||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
language: translation.language,
|
language: translation.language,
|
||||||
excerpt: translation.excerpt,
|
excerpt: translation.excerpt,
|
||||||
template_slug: post.template_slug
|
template_slug: effective_slug
|
||||||
}
|
}
|
||||||
|
|
||||||
nil ->
|
nil ->
|
||||||
@@ -227,7 +264,7 @@ defmodule BDS.Preview do
|
|||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
language: post.language,
|
language: post.language,
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt,
|
||||||
template_slug: post.template_slug
|
template_slug: effective_slug
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -270,9 +307,18 @@ defmodule BDS.Preview do
|
|||||||
defp accept_loop(listener, project_id) do
|
defp accept_loop(listener, project_id) do
|
||||||
case :gen_tcp.accept(listener) do
|
case :gen_tcp.accept(listener) do
|
||||||
{:ok, socket} ->
|
{:ok, socket} ->
|
||||||
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
case Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
||||||
serve_client(socket, project_id)
|
serve_client(socket, project_id)
|
||||||
end)
|
end) do
|
||||||
|
{:ok, pid} ->
|
||||||
|
# Hand the socket to the request task so an inflight request survives
|
||||||
|
# the acceptor being shut down (it would otherwise close the socket).
|
||||||
|
_ = :gen_tcp.controlling_process(socket, pid)
|
||||||
|
GenServer.cast(__MODULE__, {:track_request, pid})
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
accept_loop(listener, project_id)
|
accept_loop(listener, project_id)
|
||||||
|
|
||||||
@@ -395,14 +441,58 @@ defmodule BDS.Preview do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp stop_current_server(%{current: %{listener: listener, acceptor_pid: acceptor_pid}} = state) do
|
# Graceful shutdown: stop accepting new connections, then wait for inflight
|
||||||
_ = :gen_tcp.close(listener)
|
# request tasks to finish before reporting the server stopped. The stop call
|
||||||
if is_pid(acceptor_pid), do: Process.exit(acceptor_pid, :normal)
|
# is parked (no immediate reply) and finalized from the :DOWN handlers, so the
|
||||||
|
# GenServer stays available to serve the requests it is draining.
|
||||||
|
defp begin_graceful_stop(%{current: current} = state, from) do
|
||||||
|
_ = :gen_tcp.close(current.listener)
|
||||||
|
if is_pid(current.acceptor_pid), do: Process.exit(current.acceptor_pid, :normal)
|
||||||
|
|
||||||
|
if map_size(current.inflight) == 0 do
|
||||||
|
{:reply, :ok, %{state | current: nil, stopping: nil}}
|
||||||
|
else
|
||||||
|
timer = Process.send_after(self(), :drain_timeout, @drain_timeout)
|
||||||
|
{:noreply, %{state | stopping: %{from: from, timer: timer}}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_finalize_stop(
|
||||||
|
%{stopping: %{from: from, timer: timer}, current: %{inflight: inflight}} = state
|
||||||
|
)
|
||||||
|
when map_size(inflight) == 0 do
|
||||||
|
if is_reference(timer), do: Process.cancel_timer(timer)
|
||||||
|
GenServer.reply(from, :ok)
|
||||||
|
%{state | current: nil, stopping: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_finalize_stop(state), do: state
|
||||||
|
|
||||||
|
defp force_finalize_stop(%{stopping: %{from: from}, current: %{inflight: inflight}} = state) do
|
||||||
|
kill_inflight(inflight)
|
||||||
|
GenServer.reply(from, :ok)
|
||||||
|
%{state | current: nil, stopping: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp force_finalize_stop(state), do: state
|
||||||
|
|
||||||
|
# Hard stop used when restarting the server in place (no graceful drain).
|
||||||
|
defp stop_current_server(%{current: %{} = current} = state) do
|
||||||
|
_ = :gen_tcp.close(current.listener)
|
||||||
|
if is_pid(current.acceptor_pid), do: Process.exit(current.acceptor_pid, :normal)
|
||||||
|
kill_inflight(current.inflight)
|
||||||
%{state | current: nil}
|
%{state | current: nil}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp stop_current_server(state), do: state
|
defp stop_current_server(state), do: state
|
||||||
|
|
||||||
|
defp kill_inflight(inflight) do
|
||||||
|
Enum.each(inflight, fn {ref, pid} ->
|
||||||
|
Process.demonitor(ref, [:flush])
|
||||||
|
if is_pid(pid), do: Process.exit(pid, :kill)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp start_server(state, project_id, data_dir, owner_pid) do
|
defp start_server(state, project_id, data_dir, owner_pid) do
|
||||||
state = stop_current_server(state)
|
state = stop_current_server(state)
|
||||||
maybe_allow_repo(owner_pid)
|
maybe_allow_repo(owner_pid)
|
||||||
@@ -425,7 +515,8 @@ defmodule BDS.Preview do
|
|||||||
port: @port,
|
port: @port,
|
||||||
is_running: true,
|
is_running: true,
|
||||||
listener: listener,
|
listener: listener,
|
||||||
acceptor_pid: acceptor_pid
|
acceptor_pid: acceptor_pid,
|
||||||
|
inflight: %{}
|
||||||
}
|
}
|
||||||
|
|
||||||
{{:ok, public_server(server)}, %{state | current: server}}
|
{{:ok, public_server(server)}, %{state | current: server}}
|
||||||
|
|||||||
567
lib/bds/preview/router.ex
Normal file
567
lib/bds/preview/router.ex
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
defmodule BDS.Preview.Router do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.Generation.Paths
|
||||||
|
alias BDS.MapUtils
|
||||||
|
alias BDS.Metadata, as: ProjectMetadata
|
||||||
|
alias BDS.Posts
|
||||||
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Posts.Translation
|
||||||
|
alias BDS.Rendering
|
||||||
|
alias BDS.Rendering.TemplateSelection
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
|
@type route ::
|
||||||
|
{:home, pos_integer()}
|
||||||
|
| {:post, String.t(), integer(), integer(), integer()}
|
||||||
|
| {:page, String.t()}
|
||||||
|
| {:category, String.t(), pos_integer()}
|
||||||
|
| {:tag, String.t(), pos_integer()}
|
||||||
|
| {:year, integer(), pos_integer()}
|
||||||
|
| {:month, integer(), integer(), pos_integer()}
|
||||||
|
| {:day, integer(), integer(), integer(), pos_integer()}
|
||||||
|
| :not_matched
|
||||||
|
|
||||||
|
@spec render_route(String.t(), String.t()) :: {:ok, map()} | :not_matched
|
||||||
|
def render_route(project_id, request_path) do
|
||||||
|
{:ok, metadata} = ProjectMetadata.get_project_metadata(project_id)
|
||||||
|
main_language = metadata.main_language || "en"
|
||||||
|
blog_languages = metadata.blog_languages || []
|
||||||
|
additional_languages = Enum.reject(blog_languages, &(&1 == main_language))
|
||||||
|
|
||||||
|
segments = String.split(request_path, "/", trim: true)
|
||||||
|
{language, route_segments} = extract_language_prefix(segments, additional_languages)
|
||||||
|
effective_language = language || main_language
|
||||||
|
|
||||||
|
case match_route(route_segments) do
|
||||||
|
:not_matched ->
|
||||||
|
:not_matched
|
||||||
|
|
||||||
|
route ->
|
||||||
|
case render(project_id, route, effective_language, main_language, metadata) do
|
||||||
|
{:ok, body} ->
|
||||||
|
{:ok, %{content_type: "text/html", body: body}}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
:not_matched
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec match_route([String.t()]) :: route()
|
||||||
|
def match_route([]), do: {:home, 1}
|
||||||
|
def match_route(["page", n]), do: {:home, parse_page(n)}
|
||||||
|
|
||||||
|
def match_route(["category", name]), do: {:category, URI.decode(name), 1}
|
||||||
|
|
||||||
|
def match_route(["category", name, "page", n]),
|
||||||
|
do: {:category, URI.decode(name), parse_page(n)}
|
||||||
|
|
||||||
|
def match_route(["tag", name]), do: {:tag, URI.decode(name), 1}
|
||||||
|
def match_route(["tag", name, "page", n]), do: {:tag, URI.decode(name), parse_page(n)}
|
||||||
|
|
||||||
|
def match_route([y, m, d, slug]) do
|
||||||
|
with {year, ""} <- Integer.parse(y),
|
||||||
|
{month, ""} <- Integer.parse(m),
|
||||||
|
{day, ""} <- Integer.parse(d) do
|
||||||
|
{:post, slug, year, month, day}
|
||||||
|
else
|
||||||
|
_ -> :not_matched
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_route([y, m, d, "page", n]) do
|
||||||
|
with {year, ""} <- Integer.parse(y),
|
||||||
|
{month, ""} <- Integer.parse(m),
|
||||||
|
{day, ""} <- Integer.parse(d) do
|
||||||
|
{:day, year, month, day, parse_page(n)}
|
||||||
|
else
|
||||||
|
_ -> :not_matched
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_route([y, m, d]) do
|
||||||
|
with {year, ""} <- Integer.parse(y),
|
||||||
|
{month, ""} <- Integer.parse(m),
|
||||||
|
{day, ""} <- Integer.parse(d) do
|
||||||
|
{:day, year, month, day, 1}
|
||||||
|
else
|
||||||
|
_ -> :not_matched
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_route([y, m, "page", n]) do
|
||||||
|
with {year, ""} <- Integer.parse(y),
|
||||||
|
{month, ""} <- Integer.parse(m) do
|
||||||
|
{:month, year, month, parse_page(n)}
|
||||||
|
else
|
||||||
|
_ -> :not_matched
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_route([y, m]) do
|
||||||
|
with {year, ""} <- Integer.parse(y),
|
||||||
|
{month, ""} <- Integer.parse(m) do
|
||||||
|
{:month, year, month, 1}
|
||||||
|
else
|
||||||
|
_ -> :not_matched
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_route([y, "page", n]) do
|
||||||
|
with {year, ""} <- Integer.parse(y) do
|
||||||
|
{:year, year, parse_page(n)}
|
||||||
|
else
|
||||||
|
_ -> :not_matched
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_route([y]) do
|
||||||
|
case Integer.parse(y) do
|
||||||
|
{year, ""} -> {:year, year, 1}
|
||||||
|
_ -> {:page, y}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_route(_segments), do: :not_matched
|
||||||
|
|
||||||
|
## Rendering
|
||||||
|
|
||||||
|
defp render(project_id, {:home, page_number}, language, main_language, metadata) do
|
||||||
|
posts = load_published_list_posts(project_id, metadata)
|
||||||
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
||||||
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{kind: "core"})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render(project_id, {:post, slug, year, month, day}, language, main_language, _metadata) do
|
||||||
|
case find_post_by_slug_and_date(project_id, slug, year, month, day) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
post ->
|
||||||
|
render_post(project_id, post, language, main_language)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render(project_id, {:page, slug}, language, main_language, _metadata) do
|
||||||
|
case find_page_by_slug(project_id, slug) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
post -> render_post(project_id, post, language, main_language)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render(project_id, {:category, name, page_number}, language, main_language, metadata) do
|
||||||
|
posts = load_published_posts_by_category(project_id, name)
|
||||||
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
||||||
|
|
||||||
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
||||||
|
kind: "category",
|
||||||
|
name: name
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render(project_id, {:tag, name, page_number}, language, main_language, metadata) do
|
||||||
|
posts = load_published_posts_by_tag(project_id, name)
|
||||||
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
||||||
|
|
||||||
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
||||||
|
kind: "tag",
|
||||||
|
name: name
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render(project_id, {:year, year, page_number}, language, main_language, metadata) do
|
||||||
|
posts = load_published_posts_by_year(project_id, year)
|
||||||
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
||||||
|
|
||||||
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
||||||
|
kind: "date",
|
||||||
|
year: year
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render(project_id, {:month, year, month, page_number}, language, main_language, metadata) do
|
||||||
|
posts = load_published_posts_by_month(project_id, year, month)
|
||||||
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
||||||
|
|
||||||
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
||||||
|
kind: "date",
|
||||||
|
year: year,
|
||||||
|
month: month
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render(project_id, {:day, year, month, day, page_number}, language, main_language, metadata) do
|
||||||
|
posts = load_published_posts_by_day(project_id, year, month, day)
|
||||||
|
posts = maybe_resolve_language(posts, language, main_language, project_id)
|
||||||
|
|
||||||
|
render_list(project_id, posts, page_number, metadata, language, main_language, %{
|
||||||
|
kind: "date",
|
||||||
|
year: year,
|
||||||
|
month: month,
|
||||||
|
day: day
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
## Post rendering
|
||||||
|
|
||||||
|
defp render_post(project_id, post, language, main_language) do
|
||||||
|
{effective_record, body} = resolve_post_for_language(project_id, post, language, main_language)
|
||||||
|
|
||||||
|
assigns = %{
|
||||||
|
id: effective_record.id,
|
||||||
|
title: effective_record.title,
|
||||||
|
content: body,
|
||||||
|
slug: post.slug,
|
||||||
|
language: Map.get(effective_record, :language, post.language),
|
||||||
|
excerpt: Map.get(effective_record, :excerpt, post.excerpt),
|
||||||
|
_post_record: effective_record
|
||||||
|
}
|
||||||
|
|
||||||
|
effective_slug = post.template_slug || TemplateSelection.resolve_post_template_slug(project_id, post.tags, post.categories)
|
||||||
|
|
||||||
|
case Rendering.render_post_page(project_id, effective_slug, assigns) do
|
||||||
|
{:ok, rendered} -> {:ok, rendered}
|
||||||
|
{:error, _reason} -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_post_for_language(project_id, post, language, main_language) do
|
||||||
|
post_lang = String.downcase(to_string(post.language || main_language))
|
||||||
|
target_lang = String.downcase(to_string(language))
|
||||||
|
|
||||||
|
if post_lang == target_lang do
|
||||||
|
{post, Posts.editor_body(post)}
|
||||||
|
else
|
||||||
|
case Repo.get_by(Translation,
|
||||||
|
translation_for: post.id,
|
||||||
|
language: language,
|
||||||
|
project_id: project_id
|
||||||
|
) do
|
||||||
|
%Translation{status: status} = translation when status in [:published, :draft] ->
|
||||||
|
{translation, Posts.editor_body(translation)}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{post, Posts.editor_body(post)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
## List rendering
|
||||||
|
|
||||||
|
defp render_list(project_id, posts, page_number, metadata, language, main_language, archive_ctx) do
|
||||||
|
max_per_page = max(metadata.max_posts_per_page || 50, 1)
|
||||||
|
total_items = length(posts)
|
||||||
|
total_pages = Paths.page_count(total_items, max_per_page)
|
||||||
|
|
||||||
|
if page_number > total_pages and page_number > 1 do
|
||||||
|
{:error, :not_found}
|
||||||
|
else
|
||||||
|
page_posts =
|
||||||
|
posts
|
||||||
|
|> Enum.chunk_every(max_per_page)
|
||||||
|
|> Enum.at(page_number - 1, [])
|
||||||
|
|> Enum.map(&post_to_list_entry(project_id, &1, language, main_language))
|
||||||
|
|
||||||
|
language_prefix = Paths.language_prefix(language, main_language)
|
||||||
|
route_language = Paths.route_language(main_language, language)
|
||||||
|
|
||||||
|
segments = archive_context_to_segments(archive_ctx)
|
||||||
|
|
||||||
|
pagination = %{
|
||||||
|
current_page: page_number,
|
||||||
|
total_pages: total_pages,
|
||||||
|
total_items: total_items,
|
||||||
|
items_per_page: max_per_page,
|
||||||
|
has_prev_page: page_number > 1,
|
||||||
|
prev_page_href: Paths.archive_or_root_href(route_language, segments, page_number - 1),
|
||||||
|
has_next_page: page_number < total_pages,
|
||||||
|
next_page_href: Paths.archive_or_root_href(route_language, segments, page_number + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
assigns = %{
|
||||||
|
language: language,
|
||||||
|
language_prefix: language_prefix,
|
||||||
|
page_title: archive_page_title(archive_ctx),
|
||||||
|
posts: page_posts,
|
||||||
|
archive_context: archive_ctx,
|
||||||
|
pagination: pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
try do
|
||||||
|
case Rendering.render_list_page(project_id, assigns) do
|
||||||
|
{:ok, rendered} -> {:ok, rendered}
|
||||||
|
{:error, _reason} -> {:ok, fallback_list_html(page_posts, archive_ctx)}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
_ -> {:ok, fallback_list_html(page_posts, archive_ctx)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_to_list_entry(_project_id, post, language, main_language) do
|
||||||
|
route_language = Paths.route_language(main_language, language)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: post.id,
|
||||||
|
slug: post.slug,
|
||||||
|
title: post.title,
|
||||||
|
href: Paths.url_for_output(nil, Paths.post_output_path(post, route_language)),
|
||||||
|
excerpt: post.excerpt,
|
||||||
|
content: Posts.editor_body(post),
|
||||||
|
language: post.language,
|
||||||
|
author: post.author,
|
||||||
|
created_at: post.created_at,
|
||||||
|
updated_at: post.updated_at,
|
||||||
|
published_at: post.published_at,
|
||||||
|
tags: post.tags || [],
|
||||||
|
categories: post.categories || [],
|
||||||
|
template_slug: post.template_slug,
|
||||||
|
do_not_translate: Map.get(post, :do_not_translate, false)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp archive_context_to_segments(%{kind: "core"}), do: []
|
||||||
|
defp archive_context_to_segments(%{kind: "category", name: name}), do: ["category", name]
|
||||||
|
defp archive_context_to_segments(%{kind: "tag", name: name}), do: ["tag", name]
|
||||||
|
|
||||||
|
defp archive_context_to_segments(%{kind: "date", year: y, month: m, day: d})
|
||||||
|
when is_integer(y) and is_integer(m) and is_integer(d) do
|
||||||
|
[
|
||||||
|
Integer.to_string(y),
|
||||||
|
String.pad_leading(Integer.to_string(m), 2, "0"),
|
||||||
|
String.pad_leading(Integer.to_string(d), 2, "0")
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp archive_context_to_segments(%{kind: "date", year: y, month: m})
|
||||||
|
when is_integer(y) and is_integer(m) do
|
||||||
|
[Integer.to_string(y), String.pad_leading(Integer.to_string(m), 2, "0")]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp archive_context_to_segments(%{kind: "date", year: y}) when is_integer(y),
|
||||||
|
do: [Integer.to_string(y)]
|
||||||
|
|
||||||
|
defp archive_context_to_segments(_), do: []
|
||||||
|
|
||||||
|
defp fallback_list_html(posts, archive_ctx) do
|
||||||
|
title = archive_page_title(archive_ctx) || "Archive"
|
||||||
|
|
||||||
|
items =
|
||||||
|
posts
|
||||||
|
|> Enum.map(fn post ->
|
||||||
|
["<li>", to_string(Map.get(post, :title, "")), "</li>"]
|
||||||
|
end)
|
||||||
|
|> IO.iodata_to_binary()
|
||||||
|
|
||||||
|
IO.iodata_to_binary([
|
||||||
|
"<html><body><h1>",
|
||||||
|
title,
|
||||||
|
"</h1><ul>",
|
||||||
|
items,
|
||||||
|
"</ul></body></html>"
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp archive_page_title(%{kind: "category", name: name}), do: name
|
||||||
|
defp archive_page_title(%{kind: "tag", name: name}), do: name
|
||||||
|
|
||||||
|
defp archive_page_title(%{kind: "date", year: y, month: m, day: d})
|
||||||
|
when is_integer(y) and is_integer(m) and is_integer(d),
|
||||||
|
do: "#{y}-#{String.pad_leading(Integer.to_string(m), 2, "0")}-#{String.pad_leading(Integer.to_string(d), 2, "0")}"
|
||||||
|
|
||||||
|
defp archive_page_title(%{kind: "date", year: y, month: m})
|
||||||
|
when is_integer(y) and is_integer(m),
|
||||||
|
do: "#{y}-#{String.pad_leading(Integer.to_string(m), 2, "0")}"
|
||||||
|
|
||||||
|
defp archive_page_title(%{kind: "date", year: y}) when is_integer(y), do: Integer.to_string(y)
|
||||||
|
defp archive_page_title(_), do: nil
|
||||||
|
|
||||||
|
## Data loading
|
||||||
|
|
||||||
|
@default_category_settings %{
|
||||||
|
"article" => %{render_in_lists: true},
|
||||||
|
"picture" => %{render_in_lists: true},
|
||||||
|
"aside" => %{render_in_lists: true},
|
||||||
|
"page" => %{render_in_lists: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
defp load_published_list_posts(project_id, metadata) do
|
||||||
|
raw_settings = Map.get(metadata, :category_settings, %{}) || %{}
|
||||||
|
|
||||||
|
resolved =
|
||||||
|
Enum.reduce(raw_settings, @default_category_settings, fn {category, settings}, acc ->
|
||||||
|
flag =
|
||||||
|
case MapUtils.attr(settings, :render_in_lists, true) do
|
||||||
|
false -> false
|
||||||
|
_ -> true
|
||||||
|
end
|
||||||
|
|
||||||
|
Map.put(acc, category, %{render_in_lists: flag})
|
||||||
|
end)
|
||||||
|
|
||||||
|
excluded =
|
||||||
|
resolved
|
||||||
|
|> Enum.filter(fn {_cat, settings} -> settings.render_in_lists == false end)
|
||||||
|
|> Enum.map(&elem(&1, 0))
|
||||||
|
|> MapSet.new()
|
||||||
|
|
||||||
|
project_id
|
||||||
|
|> load_previewable_posts()
|
||||||
|
|> Enum.reject(fn post ->
|
||||||
|
Enum.any?(post.categories || [], &MapSet.member?(excluded, &1))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_previewable_posts(project_id) do
|
||||||
|
Repo.all(
|
||||||
|
from p in Post,
|
||||||
|
where: p.project_id == ^project_id and p.status in [:published, :draft],
|
||||||
|
order_by: [desc: p.created_at, desc: p.published_at, asc: p.slug]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_published_posts_by_category(project_id, category) do
|
||||||
|
project_id
|
||||||
|
|> load_previewable_posts()
|
||||||
|
|> Enum.filter(fn post -> category in (post.categories || []) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_published_posts_by_tag(project_id, tag) do
|
||||||
|
project_id
|
||||||
|
|> load_previewable_posts()
|
||||||
|
|> Enum.filter(fn post -> tag in (post.tags || []) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_published_posts_by_year(project_id, year) do
|
||||||
|
project_id
|
||||||
|
|> load_previewable_posts()
|
||||||
|
|> Enum.filter(fn post ->
|
||||||
|
{post_year, _, _} = Paths.local_date_parts!(post.created_at)
|
||||||
|
post_year == year
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_published_posts_by_month(project_id, year, month) do
|
||||||
|
project_id
|
||||||
|
|> load_previewable_posts()
|
||||||
|
|> Enum.filter(fn post ->
|
||||||
|
{post_year, post_month, _} = Paths.local_date_parts!(post.created_at)
|
||||||
|
post_year == year and post_month == month
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_published_posts_by_day(project_id, year, month, day) do
|
||||||
|
project_id
|
||||||
|
|> load_previewable_posts()
|
||||||
|
|> Enum.filter(fn post ->
|
||||||
|
{post_year, post_month, post_day} = Paths.local_date_parts!(post.created_at)
|
||||||
|
post_year == year and post_month == month and post_day == day
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_post_by_slug_and_date(project_id, slug, year, month, day) do
|
||||||
|
case Repo.one(
|
||||||
|
from p in Post,
|
||||||
|
where:
|
||||||
|
p.project_id == ^project_id and p.slug == ^slug and
|
||||||
|
p.status in [:published, :draft]
|
||||||
|
) do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
post ->
|
||||||
|
{post_year, post_month, post_day} = Paths.local_date_parts!(post.created_at)
|
||||||
|
|
||||||
|
if post_year == year and post_month == month and post_day == day do
|
||||||
|
post
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_page_by_slug(project_id, slug) do
|
||||||
|
case Repo.one(
|
||||||
|
from p in Post,
|
||||||
|
where:
|
||||||
|
p.project_id == ^project_id and p.slug == ^slug and
|
||||||
|
p.status in [:published, :draft]
|
||||||
|
) do
|
||||||
|
%Post{categories: categories} = post ->
|
||||||
|
if "page" in (categories || []), do: post, else: nil
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
## Language resolution
|
||||||
|
|
||||||
|
defp maybe_resolve_language(posts, language, main_language, project_id) do
|
||||||
|
if String.downcase(to_string(language)) == String.downcase(to_string(main_language)) do
|
||||||
|
posts
|
||||||
|
else
|
||||||
|
translations = load_translations_for_language(project_id, Enum.map(posts, & &1.id), language)
|
||||||
|
|
||||||
|
Enum.map(posts, fn post ->
|
||||||
|
case Map.get(translations, post.id) do
|
||||||
|
nil -> post
|
||||||
|
translation -> overlay_translation(post, translation)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_translations_for_language(project_id, post_ids, language) do
|
||||||
|
if Enum.empty?(post_ids) do
|
||||||
|
%{}
|
||||||
|
else
|
||||||
|
Repo.all(
|
||||||
|
from t in Translation,
|
||||||
|
where:
|
||||||
|
t.project_id == ^project_id and
|
||||||
|
t.translation_for in ^post_ids and
|
||||||
|
t.language == ^language and
|
||||||
|
t.status in [:published, :draft]
|
||||||
|
)
|
||||||
|
|> Map.new(&{&1.translation_for, &1})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp overlay_translation(post, translation) do
|
||||||
|
%{
|
||||||
|
post
|
||||||
|
| id: translation.id,
|
||||||
|
title: translation.title,
|
||||||
|
excerpt: translation.excerpt,
|
||||||
|
content: translation.content,
|
||||||
|
language: translation.language,
|
||||||
|
updated_at: translation.updated_at,
|
||||||
|
published_at: translation.published_at || post.published_at
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
## Helpers
|
||||||
|
|
||||||
|
defp extract_language_prefix([], _additional_languages), do: {nil, []}
|
||||||
|
|
||||||
|
defp extract_language_prefix([first | rest] = segments, additional_languages) do
|
||||||
|
normalized = String.downcase(first)
|
||||||
|
|
||||||
|
if normalized in Enum.map(additional_languages, &String.downcase/1) do
|
||||||
|
{normalized, rest}
|
||||||
|
else
|
||||||
|
{nil, segments}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_page(n) do
|
||||||
|
case Integer.parse(n) do
|
||||||
|
{page, ""} when page >= 1 -> page
|
||||||
|
_ -> 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -28,8 +28,11 @@ defmodule BDS.PreviewAssets do
|
|||||||
end)
|
end)
|
||||||
|> Enum.filter(&File.regular?/1)
|
|> Enum.filter(&File.regular?/1)
|
||||||
|> Enum.sort()
|
|> Enum.sort()
|
||||||
|> Enum.map(fn path ->
|
|> Enum.flat_map(fn path ->
|
||||||
{Path.relative_to(path, @preview_root), File.read!(path)}
|
case File.read(path) do
|
||||||
|
{:ok, contents} -> [{Path.relative_to(path, @preview_root), contents}]
|
||||||
|
{:error, _reason} -> []
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ defmodule BDS.Projects do
|
|||||||
project ->
|
project ->
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
|
previous_active_id =
|
||||||
|
Repo.one(from p in Project, where: p.is_active == true, select: p.id)
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
Repo.update_all(
|
Repo.update_all(
|
||||||
from(p in Project, where: p.is_active == true),
|
from(p in Project, where: p.is_active == true),
|
||||||
@@ -159,8 +162,16 @@ defmodule BDS.Projects do
|
|||||||
|> Repo.update!()
|
|> Repo.update!()
|
||||||
end)
|
end)
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, active_project} -> {:ok, active_project}
|
{:ok, active_project} ->
|
||||||
{:error, reason} -> {:error, reason}
|
# Force-save the outgoing project's embedding index (DebouncedPersistence).
|
||||||
|
if is_binary(previous_active_id) and previous_active_id != active_project.id do
|
||||||
|
BDS.Embeddings.Index.flush(previous_active_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, active_project}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -194,6 +205,8 @@ defmodule BDS.Projects do
|
|||||||
end)
|
end)
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, deleted_project} ->
|
{:ok, deleted_project} ->
|
||||||
|
BDS.Embeddings.Index.forget(deleted_project.id)
|
||||||
|
|
||||||
Enum.each(cleanup_dirs, fn dir ->
|
Enum.each(cleanup_dirs, fn dir ->
|
||||||
_ = File.rm_rf(dir)
|
_ = File.rm_rf(dir)
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ defmodule BDS.Publishing do
|
|||||||
{:reply, Repo.get(PublishJob, job_id), state}
|
{:reply, Repo.get(PublishJob, job_id), state}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_call({:update_job, job_id, attrs}, _from, state) do
|
def handle_call({:update_job, job_id, attrs}, _from, state) do
|
||||||
with %PublishJob{} = job <- Repo.get(PublishJob, job_id) do
|
with %PublishJob{} = job <- Repo.get(PublishJob, job_id) do
|
||||||
attrs = Map.put(attrs, :updated_at, Persistence.now_ms())
|
attrs = Map.put(attrs, :updated_at, Persistence.now_ms())
|
||||||
@@ -55,6 +56,7 @@ defmodule BDS.Publishing do
|
|||||||
{:reply, :ok, state}
|
{:reply, :ok, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_call({:should_upload_scp_file, upload_key, local_mtime}, _from, state) do
|
def handle_call({:should_upload_scp_file, upload_key, local_mtime}, _from, state) do
|
||||||
should_upload? =
|
should_upload? =
|
||||||
case state.scp_uploads[upload_key] do
|
case state.scp_uploads[upload_key] do
|
||||||
@@ -65,10 +67,12 @@ defmodule BDS.Publishing do
|
|||||||
{:reply, should_upload?, state}
|
{:reply, should_upload?, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_call({:mark_uploaded_scp_file, upload_key, local_mtime}, _from, state) do
|
def handle_call({:mark_uploaded_scp_file, upload_key, local_mtime}, _from, state) do
|
||||||
{:reply, :ok, put_in(state, [:scp_uploads, upload_key], local_mtime)}
|
{:reply, :ok, put_in(state, [:scp_uploads, upload_key], local_mtime)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_call({:upload_site, project_id, credentials, targets, opts}, _from, state) do
|
def handle_call({:upload_site, project_id, credentials, targets, opts}, _from, state) do
|
||||||
job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic]))
|
job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic]))
|
||||||
uploader = build_uploader(Keyword.put_new(opts, :project_id, project_id))
|
uploader = build_uploader(Keyword.put_new(opts, :project_id, project_id))
|
||||||
|
|||||||
@@ -77,18 +77,17 @@ defmodule BDS.ReleasePackaging do
|
|||||||
defp reset_output(metadata) do
|
defp reset_output(metadata) do
|
||||||
File.rm_rf!(metadata.payload_root)
|
File.rm_rf!(metadata.payload_root)
|
||||||
File.rm_rf!(metadata.archive_path)
|
File.rm_rf!(metadata.archive_path)
|
||||||
File.mkdir_p!(metadata.output_dir)
|
File.mkdir_p(metadata.output_dir)
|
||||||
:ok
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp copy_release(source, destination) do
|
defp copy_release(source, destination) do
|
||||||
File.mkdir_p!(Path.dirname(destination))
|
with :ok <- File.mkdir_p(Path.dirname(destination)) do
|
||||||
|
|
||||||
case File.cp_r(source, destination) do
|
case File.cp_r(source, destination) do
|
||||||
{:ok, _files} -> :ok
|
{:ok, _files} -> :ok
|
||||||
{:error, reason, _file} -> {:error, reason}
|
{:error, reason, _file} -> {:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp write_manifest(metadata) do
|
defp write_manifest(metadata) do
|
||||||
manifest = %{
|
manifest = %{
|
||||||
@@ -102,8 +101,7 @@ defmodule BDS.ReleasePackaging do
|
|||||||
}
|
}
|
||||||
|
|
||||||
manifest_path = Path.join(metadata.payload_root, "manifest.json")
|
manifest_path = Path.join(metadata.payload_root, "manifest.json")
|
||||||
File.write!(manifest_path, Jason.encode!(manifest, pretty: true))
|
File.write(manifest_path, Jason.encode!(manifest, pretty: true))
|
||||||
:ok
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_archive(%Metadata{platform: :windows} = metadata) do
|
defp create_archive(%Metadata{platform: :windows} = metadata) do
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ defmodule BDS.Rendering.Filters do
|
|||||||
|
|
||||||
use Liquex.Filter
|
use Liquex.Filter
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.Slug
|
alias BDS.Slug
|
||||||
|
alias BDS.{Repo}
|
||||||
|
alias BDS.Media.Media, as: MediaRecord
|
||||||
|
alias BDS.Posts.{Post, PostMedia}
|
||||||
|
alias BDS.Tags.Tag
|
||||||
|
require Logger
|
||||||
|
|
||||||
@spec i18n(term(), String.t(), Liquex.Context.t()) :: String.t()
|
@spec i18n(term(), String.t(), Liquex.Context.t()) :: String.t()
|
||||||
def i18n(value, language, _context) do
|
def i18n(value, language, _context) do
|
||||||
@@ -28,7 +35,7 @@ defmodule BDS.Rendering.Filters do
|
|||||||
) :: String.t()
|
) :: String.t()
|
||||||
def markdown(
|
def markdown(
|
||||||
value,
|
value,
|
||||||
_post_id,
|
post_id,
|
||||||
_post_data_json_by_id,
|
_post_data_json_by_id,
|
||||||
canonical_post_paths,
|
canonical_post_paths,
|
||||||
canonical_media_paths,
|
canonical_media_paths,
|
||||||
@@ -36,15 +43,15 @@ defmodule BDS.Rendering.Filters do
|
|||||||
_language_prefix,
|
_language_prefix,
|
||||||
context
|
context
|
||||||
) do
|
) do
|
||||||
render_markdown(value, canonical_post_paths, canonical_media_paths, language, context)
|
render_markdown(value, canonical_post_paths, canonical_media_paths, language, context, post_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec render_markdown(term(), map() | nil, map() | nil, String.t(), Liquex.Context.t()) ::
|
@spec render_markdown(term(), map() | nil, map() | nil, String.t(), Liquex.Context.t(), term()) ::
|
||||||
String.t()
|
String.t()
|
||||||
def render_markdown(value, canonical_post_paths, canonical_media_paths, language, context) do
|
def render_markdown(value, canonical_post_paths, canonical_media_paths, language, context, post_id \\ nil) do
|
||||||
value
|
value
|
||||||
|> to_string()
|
|> to_string()
|
||||||
|> replace_built_in_macros(language, context)
|
|> replace_built_in_macros(language, context, post_id)
|
||||||
|> render_markdown_html()
|
|> render_markdown_html()
|
||||||
|> rewrite_rendered_html_urls(canonical_post_paths || %{}, canonical_media_paths || %{})
|
|> rewrite_rendered_html_urls(canonical_post_paths || %{}, canonical_media_paths || %{})
|
||||||
end
|
end
|
||||||
@@ -56,7 +63,7 @@ defmodule BDS.Rendering.Filters do
|
|||||||
|> Slug.slugify()
|
|> Slug.slugify()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp replace_built_in_macros(content, language, context) do
|
defp replace_built_in_macros(content, language, context, post_id) do
|
||||||
Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match,
|
Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match,
|
||||||
macro_name,
|
macro_name,
|
||||||
raw_params ->
|
raw_params ->
|
||||||
@@ -88,6 +95,15 @@ defmodule BDS.Rendering.Filters do
|
|||||||
context
|
context
|
||||||
)
|
)
|
||||||
|
|
||||||
|
"gallery" ->
|
||||||
|
render_gallery_macro(context, params, post_id)
|
||||||
|
|
||||||
|
"photo_archive" ->
|
||||||
|
render_photo_archive_macro(context, params)
|
||||||
|
|
||||||
|
"tag_cloud" ->
|
||||||
|
render_tag_cloud_macro(context, params)
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
full_match
|
full_match
|
||||||
end
|
end
|
||||||
@@ -127,36 +143,41 @@ defmodule BDS.Rendering.Filters do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp render_macro_template(template_path, assigns, context) do
|
defp render_macro_template(template_path, assigns, context) do
|
||||||
case Map.get(assigns, "id") do
|
case BDS.Rendering.FileSystem.try_read(context.file_system, template_path) do
|
||||||
"" ->
|
{:ok, template_source} ->
|
||||||
""
|
render_macro_source(template_path, template_source, assigns, context)
|
||||||
|
|
||||||
nil ->
|
{:error, :enoent} ->
|
||||||
""
|
|
||||||
|
|
||||||
_id ->
|
|
||||||
template_source = Liquex.FileSystem.read_template_file(context.file_system, template_path)
|
|
||||||
|
|
||||||
case Liquex.parse(template_source) do
|
|
||||||
{:ok, template_ast} ->
|
|
||||||
isolated_context = Liquex.Context.new_isolated_subscope(context, assigns)
|
|
||||||
|
|
||||||
try do
|
|
||||||
{result, _context} = Liquex.render!(template_ast, isolated_context)
|
|
||||||
IO.iodata_to_binary(result)
|
|
||||||
rescue
|
|
||||||
e in Liquex.Error ->
|
|
||||||
require Logger
|
require Logger
|
||||||
Logger.warning("Macro template render failed (#{template_path}): #{e.message}")
|
Logger.warning("Macro template not found: #{template_path}")
|
||||||
""
|
""
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_macro_source(template_path, template_source, assigns, context) do
|
||||||
|
with {:ok, template_ast} <- Liquex.parse(template_source),
|
||||||
|
{:ok, rendered} <- safe_liquex_render(template_ast, context, assigns) do
|
||||||
|
rendered
|
||||||
|
else
|
||||||
{:error, reason, line} ->
|
{:error, reason, line} ->
|
||||||
require Logger
|
require Logger
|
||||||
Logger.warning("Macro template parse failed (#{template_path}): #{reason} at line #{line}")
|
Logger.warning("Macro template parse failed (#{template_path}): #{reason} at line #{line}")
|
||||||
""
|
""
|
||||||
|
|
||||||
|
{:error, message} ->
|
||||||
|
require Logger
|
||||||
|
Logger.warning("Macro template render failed (#{template_path}): #{message}")
|
||||||
|
""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp safe_liquex_render(template_ast, context, assigns) do
|
||||||
|
isolated_context = Liquex.Context.new_isolated_subscope(context, assigns)
|
||||||
|
|
||||||
|
{result, _context} = Liquex.render!(template_ast, isolated_context)
|
||||||
|
{:ok, IO.iodata_to_binary(result)}
|
||||||
|
rescue
|
||||||
|
e in Liquex.Error -> {:error, e.message}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp render_markdown_html(markdown) do
|
defp render_markdown_html(markdown) do
|
||||||
@@ -271,4 +292,307 @@ defmodule BDS.Rendering.Filters do
|
|||||||
|
|
||||||
defp ensure_leading_slash("/" <> _rest = path), do: path
|
defp ensure_leading_slash("/" <> _rest = path), do: path
|
||||||
defp ensure_leading_slash(path), do: "/" <> path
|
defp ensure_leading_slash(path), do: "/" <> path
|
||||||
|
|
||||||
|
# ── Built-in macro renderers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp render_gallery_macro(context, params, post_id) when is_binary(post_id) do
|
||||||
|
columns = normalize_columns(Map.get(params, "columns", "3"), 3, 1, 6)
|
||||||
|
caption = Map.get(params, "caption")
|
||||||
|
|
||||||
|
items =
|
||||||
|
post_id
|
||||||
|
|> linked_media_images()
|
||||||
|
|> Enum.map(fn media ->
|
||||||
|
%{
|
||||||
|
"media_path" => "/#{media.file_path}",
|
||||||
|
"title" => media.title || media.original_name,
|
||||||
|
"alt" => media.alt || media.title || media.original_name,
|
||||||
|
"group_name" => post_id
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
render_macro_template(
|
||||||
|
"macros/gallery",
|
||||||
|
%{
|
||||||
|
"columns" => columns,
|
||||||
|
"post_id" => post_id,
|
||||||
|
"items" => items,
|
||||||
|
"caption" => caption,
|
||||||
|
"empty_label" =>
|
||||||
|
BDS.Gettext.lgettext(
|
||||||
|
Access.get(context, "language") || "en",
|
||||||
|
"render",
|
||||||
|
"No images"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
context
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_gallery_macro(context, params, _post_id) do
|
||||||
|
render_macro_template(
|
||||||
|
"macros/gallery",
|
||||||
|
%{
|
||||||
|
"columns" => normalize_columns(Map.get(params, "columns", "3"), 3, 1, 6),
|
||||||
|
"post_id" => "",
|
||||||
|
"items" => [],
|
||||||
|
"caption" => Map.get(params, "caption"),
|
||||||
|
"empty_label" =>
|
||||||
|
BDS.Gettext.lgettext(
|
||||||
|
Access.get(context, "language") || "en",
|
||||||
|
"render",
|
||||||
|
"No images"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
context
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_photo_archive_macro(context, params) do
|
||||||
|
language = Access.get(context, "language") || "en"
|
||||||
|
project_id = project_id_from_context(context)
|
||||||
|
|
||||||
|
months =
|
||||||
|
if project_id do
|
||||||
|
media_month_archive(project_id, Map.get(params, "year"), Map.get(params, "month"))
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
render_macro_template(
|
||||||
|
"macros/photo-archive",
|
||||||
|
%{
|
||||||
|
"root_classes" => "macro-photo-archive",
|
||||||
|
"data_attrs" => [],
|
||||||
|
"months" => months,
|
||||||
|
"empty_label" =>
|
||||||
|
BDS.Gettext.lgettext(language, "render", "No photos found")
|
||||||
|
},
|
||||||
|
context
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_tag_cloud_macro(context, params) do
|
||||||
|
language = Access.get(context, "language") || "en"
|
||||||
|
project_id = project_id_from_context(context)
|
||||||
|
|
||||||
|
{words_json, width, height} =
|
||||||
|
if project_id do
|
||||||
|
build_tag_cloud_data(project_id)
|
||||||
|
else
|
||||||
|
{nil, 800, 400}
|
||||||
|
end
|
||||||
|
|
||||||
|
render_macro_template(
|
||||||
|
"macros/tag-cloud",
|
||||||
|
%{
|
||||||
|
"orientation" => Map.get(params, "orientation", "horizontal"),
|
||||||
|
"words_json" => words_json,
|
||||||
|
"width" => Map.get(params, "width", width),
|
||||||
|
"height" => Map.get(params, "height", height),
|
||||||
|
"aria_label" => "Tag cloud",
|
||||||
|
"empty_label" =>
|
||||||
|
BDS.Gettext.lgettext(language, "render", "No tags")
|
||||||
|
},
|
||||||
|
context
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Data queries for macros ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp linked_media_images(post_id) do
|
||||||
|
Repo.all(
|
||||||
|
from pm in PostMedia,
|
||||||
|
join: m in MediaRecord,
|
||||||
|
on: pm.media_id == m.id,
|
||||||
|
where: pm.post_id == ^post_id,
|
||||||
|
where: like(m.mime_type, "image/%"),
|
||||||
|
order_by: [asc: pm.sort_order, asc: pm.media_id],
|
||||||
|
select: m
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_month_archive(project_id, year, month) do
|
||||||
|
query =
|
||||||
|
from m in MediaRecord,
|
||||||
|
where: m.project_id == ^project_id,
|
||||||
|
where: like(m.mime_type, "image/%"),
|
||||||
|
order_by: [desc: m.created_at],
|
||||||
|
select: m
|
||||||
|
|
||||||
|
query =
|
||||||
|
if year do
|
||||||
|
year_int = parse_integer(year)
|
||||||
|
|
||||||
|
if month do
|
||||||
|
month_int = parse_integer(month)
|
||||||
|
start_ts = month_start_ms(year_int, month_int)
|
||||||
|
end_ts = month_end_ms(year_int, month_int)
|
||||||
|
|
||||||
|
from m in query,
|
||||||
|
where: m.created_at >= ^start_ts and m.created_at <= ^end_ts
|
||||||
|
else
|
||||||
|
start_ts = month_start_ms(year_int, 1)
|
||||||
|
end_ts = month_end_ms(year_int, 12)
|
||||||
|
|
||||||
|
from m in query,
|
||||||
|
where: m.created_at >= ^start_ts and m.created_at <= ^end_ts
|
||||||
|
end
|
||||||
|
else
|
||||||
|
from m in query, limit: 200
|
||||||
|
end
|
||||||
|
|
||||||
|
media_records =
|
||||||
|
query
|
||||||
|
|> Repo.all()
|
||||||
|
|> group_by_media_month()
|
||||||
|
|
||||||
|
if year == nil do
|
||||||
|
Enum.take(media_records, 10)
|
||||||
|
else
|
||||||
|
media_records
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp group_by_media_month(media_records) do
|
||||||
|
month_names = %{
|
||||||
|
1 => "January", 2 => "February", 3 => "March", 4 => "April",
|
||||||
|
5 => "May", 6 => "June", 7 => "July", 8 => "August",
|
||||||
|
9 => "September", 10 => "October", 11 => "November", 12 => "December"
|
||||||
|
}
|
||||||
|
|
||||||
|
media_records
|
||||||
|
|> Enum.group_by(fn m ->
|
||||||
|
date = DateTime.from_unix!(div(m.created_at, 1000))
|
||||||
|
{date.year, date.month}
|
||||||
|
end)
|
||||||
|
|> Enum.sort_by(fn {{y, m}, _} -> {y, m} end, :desc)
|
||||||
|
|> Enum.map(fn {{year, month}, items} ->
|
||||||
|
%{
|
||||||
|
"label" => "#{Map.get(month_names, month)} #{year}",
|
||||||
|
"items" =>
|
||||||
|
Enum.map(items, fn m ->
|
||||||
|
%{
|
||||||
|
"media_path" => "/#{m.file_path}",
|
||||||
|
"title" => m.title || m.original_name,
|
||||||
|
"alt" => m.alt || m.title || m.original_name,
|
||||||
|
"group_name" => "#{year}-#{month}"
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_tag_cloud_data(project_id) do
|
||||||
|
tag_colors =
|
||||||
|
Repo.all(
|
||||||
|
from tag in Tag,
|
||||||
|
where: tag.project_id == ^project_id,
|
||||||
|
where: not is_nil(tag.color) and tag.color != "",
|
||||||
|
select: {tag.name, tag.color}
|
||||||
|
)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
%{rows: rows} =
|
||||||
|
Ecto.Adapters.SQL.query!(
|
||||||
|
Repo,
|
||||||
|
"""
|
||||||
|
SELECT trim(je.value) AS tag, COUNT(*) AS cnt
|
||||||
|
FROM posts, json_each(posts.tags) je
|
||||||
|
WHERE posts.project_id = ?1
|
||||||
|
AND trim(je.value) != ''
|
||||||
|
GROUP BY tag
|
||||||
|
ORDER BY cnt DESC, lower(tag) ASC
|
||||||
|
""",
|
||||||
|
[project_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
tag_entries =
|
||||||
|
Enum.map(rows, fn [tag, count] ->
|
||||||
|
%{tag: tag, count: count, color: Map.get(tag_colors, tag)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
if tag_entries == [] do
|
||||||
|
{nil, 0, 0}
|
||||||
|
else
|
||||||
|
max_count = Enum.map(tag_entries, & &1.count) |> Enum.max()
|
||||||
|
min_count = Enum.map(tag_entries, & &1.count) |> Enum.min()
|
||||||
|
range = max(max_count - min_count, 1)
|
||||||
|
|
||||||
|
words =
|
||||||
|
Enum.map(tag_entries, fn %{tag: tag, count: count, color: color} ->
|
||||||
|
size = 12.0 + (count - min_count) / range * 28.0
|
||||||
|
%{"text" => tag, "size" => size, "color" => color || "var(--accent-color)"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
{Jason.encode!(words), 800, 400}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp project_id_from_context(context) do
|
||||||
|
post = Access.get(context, "post") || %{}
|
||||||
|
post["project_id"] || Access.get(post, :project_id) || project_id_from_post(context)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp project_id_from_post(context) do
|
||||||
|
post_id =
|
||||||
|
Access.get(Access.get(context, "post") || %{}, "id") ||
|
||||||
|
Access.get(Access.get(context, "post") || %{}, :id)
|
||||||
|
|
||||||
|
if is_binary(post_id) do
|
||||||
|
case Repo.one(from p in Post, where: p.id == ^post_id, select: p.project_id) do
|
||||||
|
nil -> nil
|
||||||
|
project_id -> project_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_columns(value, default, min, max) when is_binary(value) do
|
||||||
|
case Integer.parse(value) do
|
||||||
|
{n, ""} -> n |> max(min) |> min(max)
|
||||||
|
_ -> default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
defp normalize_columns(value, _default, min, max) when is_integer(value),
|
||||||
|
do: value |> max(min) |> min(max)
|
||||||
|
defp normalize_columns(_value, default, _min, _max), do: default
|
||||||
|
|
||||||
|
defp parse_integer(value) when is_binary(value) do
|
||||||
|
case Integer.parse(value) do
|
||||||
|
{n, ""} -> n
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
defp parse_integer(value) when is_integer(value), do: value
|
||||||
|
defp parse_integer(_value), do: nil
|
||||||
|
|
||||||
|
defp month_start_ms(year, month) do
|
||||||
|
case DateTime.from_iso8601("#{year}-#{pad(month)}-01T00:00:00Z") do
|
||||||
|
{:ok, dt, _} -> DateTime.to_unix(dt, :millisecond)
|
||||||
|
_ -> 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp month_end_ms(year, month) do
|
||||||
|
last_day =
|
||||||
|
if month == 12 do
|
||||||
|
31
|
||||||
|
else
|
||||||
|
case DateTime.from_iso8601("#{year}-#{pad(month + 1)}-01T00:00:00Z") do
|
||||||
|
{:ok, dt, _} ->
|
||||||
|
dt |> DateTime.add(-1, :second) |> DateTime.to_date() |> Map.get(:day)
|
||||||
|
_ ->
|
||||||
|
31
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
case DateTime.from_iso8601("#{year}-#{pad(month)}-#{pad(last_day)}T23:59:59.999Z") do
|
||||||
|
{:ok, dt, _} -> DateTime.to_unix(dt, :millisecond)
|
||||||
|
_ -> 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pad(n) when is_integer(n), do: n |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ defmodule BDS.Rendering.Labels do
|
|||||||
language_switcher_label: dgettext("render", "Language"),
|
language_switcher_label: dgettext("render", "Language"),
|
||||||
site_search_label: dgettext("render", "Site search"),
|
site_search_label: dgettext("render", "Site search"),
|
||||||
search_placeholder: dgettext("render", "Search..."),
|
search_placeholder: dgettext("render", "Search..."),
|
||||||
|
search_no_results: dgettext("render", "No results found"),
|
||||||
not_found_message: dgettext("render", "The requested preview page could not be found."),
|
not_found_message: dgettext("render", "The requested preview page could not be found."),
|
||||||
not_found_back_label: dgettext("render", "Back to preview home"),
|
not_found_back_label: dgettext("render", "Back to preview home"),
|
||||||
youtube_video: dgettext("render", "YouTube video"),
|
youtube_video: dgettext("render", "YouTube video"),
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ defmodule BDS.Rendering.PostRendering do
|
|||||||
alias BDS.Rendering.TemplateSelection
|
alias BDS.Rendering.TemplateSelection
|
||||||
alias BDS.MapUtils
|
alias BDS.MapUtils
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Posts.PostMedia
|
||||||
alias BDS.Posts.Translation
|
alias BDS.Posts.Translation
|
||||||
|
alias BDS.Media.Media, as: MediaRecord
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
|
|
||||||
@spec post_assigns(String.t(), map()) :: map()
|
@spec post_assigns(String.t(), map()) :: map()
|
||||||
@@ -39,7 +41,8 @@ defmodule BDS.Rendering.PostRendering do
|
|||||||
canonical_post_paths,
|
canonical_post_paths,
|
||||||
canonical_media_paths,
|
canonical_media_paths,
|
||||||
language,
|
language,
|
||||||
template_context
|
template_context,
|
||||||
|
post_id
|
||||||
)
|
)
|
||||||
|
|
||||||
incoming_links =
|
incoming_links =
|
||||||
@@ -208,6 +211,7 @@ defmodule BDS.Rendering.PostRendering do
|
|||||||
title: MapUtils.attr(assigns, :title),
|
title: MapUtils.attr(assigns, :title),
|
||||||
content: MapUtils.attr(assigns, :content),
|
content: MapUtils.attr(assigns, :content),
|
||||||
raw_content: MapUtils.attr(assigns, :raw_content),
|
raw_content: MapUtils.attr(assigns, :raw_content),
|
||||||
|
project_id: MapUtils.attr(assigns, :project_id) || Map.get(post_record || %{}, :project_id),
|
||||||
excerpt:
|
excerpt:
|
||||||
Map.get(
|
Map.get(
|
||||||
assigns,
|
assigns,
|
||||||
@@ -234,7 +238,7 @@ defmodule BDS.Rendering.PostRendering do
|
|||||||
MapUtils.attr(assigns, :template_slug)
|
MapUtils.attr(assigns, :template_slug)
|
||||||
),
|
),
|
||||||
do_not_translate: Map.get(post_record || %{}, :do_not_translate, false),
|
do_not_translate: Map.get(post_record || %{}, :do_not_translate, false),
|
||||||
linked_media: [],
|
linked_media: linked_media_images(assigns),
|
||||||
outgoing_links: outgoing_links,
|
outgoing_links: outgoing_links,
|
||||||
incoming_links: incoming_links
|
incoming_links: incoming_links
|
||||||
}
|
}
|
||||||
@@ -245,21 +249,42 @@ defmodule BDS.Rendering.PostRendering do
|
|||||||
map(),
|
map(),
|
||||||
map(),
|
map(),
|
||||||
String.t(),
|
String.t(),
|
||||||
Liquex.Context.t()
|
Liquex.Context.t(),
|
||||||
|
term()
|
||||||
) :: String.t()
|
) :: String.t()
|
||||||
def render_post_content(
|
def render_post_content(
|
||||||
content,
|
content,
|
||||||
canonical_post_paths,
|
canonical_post_paths,
|
||||||
canonical_media_paths,
|
canonical_media_paths,
|
||||||
language,
|
language,
|
||||||
template_context
|
template_context,
|
||||||
|
post_id \\ nil
|
||||||
) do
|
) do
|
||||||
Filters.render_markdown(
|
Filters.render_markdown(
|
||||||
content,
|
content,
|
||||||
canonical_post_paths,
|
canonical_post_paths,
|
||||||
canonical_media_paths,
|
canonical_media_paths,
|
||||||
language,
|
language,
|
||||||
template_context
|
template_context,
|
||||||
|
post_id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp linked_media_images(assigns) do
|
||||||
|
post_id = MapUtils.attr(assigns, :id)
|
||||||
|
|
||||||
|
if is_binary(post_id) do
|
||||||
|
Repo.all(
|
||||||
|
from pm in PostMedia,
|
||||||
|
join: m in MediaRecord,
|
||||||
|
on: pm.media_id == m.id,
|
||||||
|
where: pm.post_id == ^post_id,
|
||||||
|
where: like(m.mime_type, "image/%"),
|
||||||
|
order_by: [asc: pm.sort_order, asc: pm.media_id],
|
||||||
|
select: m
|
||||||
|
)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,13 +4,52 @@ defmodule BDS.Rendering.TemplateSelection do
|
|||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.Frontmatter
|
alias BDS.Frontmatter
|
||||||
|
alias BDS.Metadata
|
||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.Rendering.FileSystem
|
alias BDS.Rendering.FileSystem
|
||||||
alias BDS.Rendering.Filters
|
alias BDS.Rendering.Filters
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.StarterTemplates
|
alias BDS.StarterTemplates
|
||||||
|
alias BDS.Tags.Tag
|
||||||
alias BDS.Templates.Template
|
alias BDS.Templates.Template
|
||||||
|
|
||||||
|
@spec resolve_post_template_slug(String.t(), [String.t()], [String.t()]) ::
|
||||||
|
String.t() | nil
|
||||||
|
def resolve_post_template_slug(project_id, tag_names, category_names) do
|
||||||
|
resolve_from_tags(project_id, tag_names) ||
|
||||||
|
resolve_from_categories(project_id, category_names)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_from_tags(_project_id, []), do: nil
|
||||||
|
|
||||||
|
defp resolve_from_tags(project_id, tag_names) do
|
||||||
|
Repo.all(
|
||||||
|
from tag in Tag,
|
||||||
|
where:
|
||||||
|
tag.project_id == ^project_id and
|
||||||
|
tag.name in ^tag_names and
|
||||||
|
not is_nil(tag.post_template_slug) and
|
||||||
|
tag.post_template_slug != "",
|
||||||
|
select: tag.post_template_slug,
|
||||||
|
limit: 1
|
||||||
|
)
|
||||||
|
|> List.first()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_from_categories(_project_id, []), do: nil
|
||||||
|
|
||||||
|
defp resolve_from_categories(project_id, category_names) do
|
||||||
|
{:ok, state} = Metadata.get_project_metadata(project_id)
|
||||||
|
settings = state.category_settings || %{}
|
||||||
|
|
||||||
|
Enum.find_value(category_names, fn cat_name ->
|
||||||
|
case Map.get(settings, cat_name) do
|
||||||
|
%{"post_template_slug" => slug} when is_binary(slug) and slug != "" -> slug
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
@spec load_template_source(String.t(), atom(), String.t() | nil) ::
|
@spec load_template_source(String.t(), atom(), String.t() | nil) ::
|
||||||
{:ok, String.t()} | {:error, term()}
|
{:ok, String.t()} | {:error, term()}
|
||||||
def load_template_source(project_id, kind, slug) do
|
def load_template_source(project_id, kind, slug) do
|
||||||
@@ -93,7 +132,16 @@ defmodule BDS.Rendering.TemplateSelection do
|
|||||||
@spec render_template(String.t(), String.t(), map()) ::
|
@spec render_template(String.t(), String.t(), map()) ::
|
||||||
{:ok, String.t()} | {:error, String.t()}
|
{:ok, String.t()} | {:error, String.t()}
|
||||||
def render_template(project_id, source, assigns) do
|
def render_template(project_id, source, assigns) do
|
||||||
with {:ok, template_ast} <- Liquex.parse(source) do
|
with {:ok, template_ast} <- Liquex.parse(source),
|
||||||
|
{:ok, _rendered} = ok <- safe_liquex_render(template_ast, project_id, assigns) do
|
||||||
|
ok
|
||||||
|
else
|
||||||
|
{:error, reason, line} when is_integer(line) -> {:error, "#{reason} at line #{line}"}
|
||||||
|
{:error, _message} = error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp safe_liquex_render(template_ast, project_id, assigns) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
|
|
||||||
context =
|
context =
|
||||||
@@ -103,35 +151,26 @@ defmodule BDS.Rendering.TemplateSelection do
|
|||||||
file_system: FileSystem.new(StarterTemplates.template_roots(project))
|
file_system: FileSystem.new(StarterTemplates.template_roots(project))
|
||||||
)
|
)
|
||||||
|
|
||||||
try do
|
|
||||||
{result, _context} = Liquex.render!(template_ast, context)
|
{result, _context} = Liquex.render!(template_ast, context)
|
||||||
{:ok, IO.iodata_to_binary(result)}
|
{:ok, IO.iodata_to_binary(result)}
|
||||||
rescue
|
rescue
|
||||||
e in Liquex.Error -> {:error, e.message}
|
e in Liquex.Error -> {:error, e.message}
|
||||||
end
|
end
|
||||||
else
|
|
||||||
{:error, reason, line} -> {:error, "#{reason} at line #{line}"}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_bundled_template_source(project, kind, slug) do
|
defp load_bundled_template_source(project, kind, slug) do
|
||||||
desired_slug = bundled_template_slug(kind, slug)
|
desired_slug = bundled_template_slug(kind, slug)
|
||||||
|
|
||||||
if is_binary(desired_slug) do
|
with true <- is_binary(desired_slug),
|
||||||
file_system = project |> StarterTemplates.template_roots() |> FileSystem.new()
|
file_system = project |> StarterTemplates.template_roots() |> FileSystem.new(),
|
||||||
source = Liquex.FileSystem.read_template_file(file_system, desired_slug)
|
{:ok, source} <- FileSystem.try_read(file_system, desired_slug) do
|
||||||
|
|
||||||
case Frontmatter.parse_document(source) do
|
case Frontmatter.parse_document(source) do
|
||||||
{:ok, %{body: body}} -> {:ok, body}
|
{:ok, %{body: body}} -> {:ok, body}
|
||||||
{:error, :invalid_frontmatter} -> {:ok, source}
|
{:error, :invalid_frontmatter} -> {:ok, source}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
{:error, :template_not_found}
|
false -> {:error, :template_not_found}
|
||||||
|
{:error, :enoent} -> {:error, :template_not_found}
|
||||||
end
|
end
|
||||||
rescue
|
|
||||||
error in [Liquex.Error] ->
|
|
||||||
_ = error
|
|
||||||
{:error, :template_not_found}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_load_bundled_template_source(project, kind, slug, template, reason, error)
|
defp maybe_load_bundled_template_source(project, kind, slug, template, reason, error)
|
||||||
|
|||||||
@@ -57,10 +57,13 @@ defmodule BDS.Scripts do
|
|||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
|
|
||||||
script ->
|
script ->
|
||||||
|
content = script.content || ""
|
||||||
|
|
||||||
|
case validate_script(content) do
|
||||||
|
:ok ->
|
||||||
file_path = script_file_path(script.slug)
|
file_path = script_file_path(script.slug)
|
||||||
full_path = full_file_path(script.project_id, file_path)
|
full_path = full_file_path(script.project_id, file_path)
|
||||||
updated_at = Persistence.now_ms()
|
updated_at = Persistence.now_ms()
|
||||||
content = script.content || ""
|
|
||||||
|
|
||||||
:ok =
|
:ok =
|
||||||
Persistence.atomic_write(
|
Persistence.atomic_write(
|
||||||
@@ -79,6 +82,10 @@ defmodule BDS.Scripts do
|
|||||||
updated_at: updated_at
|
updated_at: updated_at
|
||||||
})
|
})
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, {:invalid_script, reason}}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -254,6 +261,13 @@ defmodule BDS.Scripts do
|
|||||||
not Repo.exists?(scoped_query)
|
not Repo.exists?(scoped_query)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp validate_script(source) do
|
||||||
|
case BDS.Scripting.validate(source) do
|
||||||
|
:ok -> :ok
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp script_file_path(slug), do: Path.join(["scripts", "#{slug}.lua"])
|
defp script_file_path(slug), do: Path.join(["scripts", "#{slug}.lua"])
|
||||||
|
|
||||||
defp full_file_path(project_id, relative_path) do
|
defp full_file_path(project_id, relative_path) do
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ defmodule BDS.Templates do
|
|||||||
including slug derivation, status transitions, and filesystem synchronization.
|
including slug derivation, status transitions, and filesystem synchronization.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||||
|
|
||||||
@@ -26,23 +28,38 @@ defmodule BDS.Templates do
|
|||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
project_id = attr(attrs, :project_id)
|
project_id = attr(attrs, :project_id)
|
||||||
title = attr(attrs, :title) || ""
|
title = attr(attrs, :title) || ""
|
||||||
|
slug = unique_slug(project_id, Slug.slugify(title), "template")
|
||||||
|
file_path = template_file_path(slug)
|
||||||
|
|
||||||
|
changeset =
|
||||||
%Template{}
|
%Template{}
|
||||||
|> Template.changeset(%{
|
|> Template.changeset(%{
|
||||||
id: Ecto.UUID.generate(),
|
id: Ecto.UUID.generate(),
|
||||||
project_id: project_id,
|
project_id: project_id,
|
||||||
slug: unique_slug(project_id, Slug.slugify(title), "template"),
|
slug: slug,
|
||||||
title: title,
|
title: title,
|
||||||
kind: attr(attrs, :kind),
|
kind: attr(attrs, :kind),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
version: 1,
|
version: 1,
|
||||||
file_path: "",
|
file_path: file_path,
|
||||||
status: :draft,
|
status: :draft,
|
||||||
content: attr(attrs, :content),
|
content: attr(attrs, :content),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now
|
updated_at: now
|
||||||
})
|
})
|
||||||
|> Repo.insert()
|
|
||||||
|
with {:ok, template} <- Repo.insert(changeset) do
|
||||||
|
full_path = full_file_path(template.project_id, file_path)
|
||||||
|
File.mkdir_p!(Path.dirname(full_path))
|
||||||
|
|
||||||
|
:ok =
|
||||||
|
Persistence.atomic_write(
|
||||||
|
full_path,
|
||||||
|
serialize_template_file(template, template.content || "")
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, template}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec get_template(String.t()) :: Template.t() | nil
|
@spec get_template(String.t()) :: Template.t() | nil
|
||||||
@@ -60,10 +77,13 @@ defmodule BDS.Templates do
|
|||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
|
|
||||||
template ->
|
template ->
|
||||||
|
content = template.content || ""
|
||||||
|
|
||||||
|
case validate_liquid(content) do
|
||||||
|
:ok ->
|
||||||
file_path = template_file_path(template.slug)
|
file_path = template_file_path(template.slug)
|
||||||
full_path = full_file_path(template.project_id, file_path)
|
full_path = full_file_path(template.project_id, file_path)
|
||||||
updated_at = Persistence.now_ms()
|
updated_at = Persistence.now_ms()
|
||||||
content = template.content || ""
|
|
||||||
|
|
||||||
:ok =
|
:ok =
|
||||||
Persistence.atomic_write(
|
Persistence.atomic_write(
|
||||||
@@ -82,6 +102,10 @@ defmodule BDS.Templates do
|
|||||||
updated_at: updated_at
|
updated_at: updated_at
|
||||||
})
|
})
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, {:invalid_liquid, reason}}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -184,10 +208,18 @@ defmodule BDS.Templates do
|
|||||||
templates =
|
templates =
|
||||||
template_paths
|
template_paths
|
||||||
|> Enum.with_index(1)
|
|> Enum.with_index(1)
|
||||||
|> Enum.map(fn {path, index} ->
|
|> Enum.flat_map(fn {path, index} ->
|
||||||
template = upsert_template_from_file(project_id, project, path)
|
result = upsert_template_from_file(project_id, project, path)
|
||||||
:ok = report_rebuild_progress(on_progress, index, total_files, "template files")
|
:ok = report_rebuild_progress(on_progress, index, total_files, "template files")
|
||||||
template
|
|
||||||
|
case result do
|
||||||
|
{:ok, template} ->
|
||||||
|
[template]
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Skipping template #{path}: #{inspect(reason)}")
|
||||||
|
[]
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
remove_stale_published_templates(project_id, project, template_paths)
|
remove_stale_published_templates(project_id, project, template_paths)
|
||||||
@@ -241,10 +273,9 @@ defmodule BDS.Templates do
|
|||||||
project = Projects.get_project!(template.project_id)
|
project = Projects.get_project!(template.project_id)
|
||||||
full_path = Path.join(Projects.project_data_dir(project), template.file_path)
|
full_path = Path.join(Projects.project_data_dir(project), template.file_path)
|
||||||
|
|
||||||
if File.exists?(full_path) do
|
case upsert_template_from_file(template.project_id, project, full_path) do
|
||||||
{:ok, upsert_template_from_file(template.project_id, project, full_path)}
|
{:ok, _template} = ok -> ok
|
||||||
else
|
{:error, _reason} -> {:error, :not_found}
|
||||||
{:error, :not_found}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -278,10 +309,9 @@ defmodule BDS.Templates do
|
|||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
full_path = Path.join(Projects.project_data_dir(project), relative_path)
|
full_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||||
|
|
||||||
if File.exists?(full_path) do
|
case upsert_template_from_file(project_id, project, full_path) do
|
||||||
{:ok, upsert_template_from_file(project_id, project, full_path)}
|
{:ok, _template} = ok -> ok
|
||||||
else
|
{:error, _reason} -> {:error, :not_found}
|
||||||
{:error, :not_found}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -319,6 +349,13 @@ defmodule BDS.Templates do
|
|||||||
not Repo.exists?(scoped_query)
|
not Repo.exists?(scoped_query)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp validate_liquid(source) do
|
||||||
|
case Liquex.parse(source) do
|
||||||
|
{:ok, _ast} -> :ok
|
||||||
|
{:error, reason, line} -> {:error, "#{reason} at line #{line}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp template_file_path(slug), do: Path.join(["templates", "#{slug}.liquid"])
|
defp template_file_path(slug), do: Path.join(["templates", "#{slug}.liquid"])
|
||||||
|
|
||||||
defp full_file_path(project_id, relative_path) do
|
defp full_file_path(project_id, relative_path) do
|
||||||
@@ -326,7 +363,6 @@ defmodule BDS.Templates do
|
|||||||
Path.join(Projects.project_data_dir(project), relative_path)
|
Path.join(Projects.project_data_dir(project), relative_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp next_template_file_path(%Template{file_path: ""}, _next_slug), do: ""
|
|
||||||
defp next_template_file_path(%Template{}, next_slug), do: template_file_path(next_slug)
|
defp next_template_file_path(%Template{}, next_slug), do: template_file_path(next_slug)
|
||||||
|
|
||||||
defp serialize_template_file(template, content) do
|
defp serialize_template_file(template, content) do
|
||||||
@@ -448,13 +484,12 @@ defmodule BDS.Templates do
|
|||||||
body = published_template_body(original_template)
|
body = published_template_body(original_template)
|
||||||
new_full_path = full_file_path(updated_template.project_id, updated_template.file_path)
|
new_full_path = full_file_path(updated_template.project_id, updated_template.file_path)
|
||||||
|
|
||||||
result =
|
case Persistence.atomic_write(new_full_path, serialize_template_file(updated_template, body)) do
|
||||||
Persistence.atomic_write(new_full_path, serialize_template_file(updated_template, body))
|
:ok when original_template.file_path != updated_template.file_path ->
|
||||||
|
|
||||||
if result == :ok and original_template.file_path != updated_template.file_path do
|
|
||||||
delete_file_if_present(original_template.project_id, original_template.file_path)
|
delete_file_if_present(original_template.project_id, original_template.file_path)
|
||||||
else
|
|
||||||
result
|
other ->
|
||||||
|
other
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -494,9 +529,10 @@ defmodule BDS.Templates do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp upsert_template_from_file(project_id, project, path) do
|
defp upsert_template_from_file(project_id, project, path) do
|
||||||
contents = File.read!(path)
|
|
||||||
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
|
|
||||||
relative_path = Path.relative_to(path, Projects.project_data_dir(project))
|
relative_path = Path.relative_to(path, Projects.project_data_dir(project))
|
||||||
|
|
||||||
|
with {:ok, contents} <- File.read(path),
|
||||||
|
{:ok, %{fields: fields}} <- Frontmatter.parse_document(contents) do
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
attrs = %{
|
attrs = %{
|
||||||
@@ -518,7 +554,8 @@ defmodule BDS.Templates do
|
|||||||
|
|
||||||
template
|
template
|
||||||
|> Template.changeset(attrs)
|
|> Template.changeset(attrs)
|
||||||
|> Repo.insert_or_update!()
|
|> Repo.insert_or_update()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp remove_stale_published_templates(project_id, project, template_paths) do
|
defp remove_stale_published_templates(project_id, project, template_paths) do
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ defmodule BDS.UI.Sidebar do
|
|||||||
"import",
|
"import",
|
||||||
list_import_definitions(project_id)
|
list_import_definitions(project_id)
|
||||||
),
|
),
|
||||||
"git" => git_view(),
|
"git" => git_view(project_id),
|
||||||
"settings" => settings_nav_view()
|
"settings" => settings_nav_view()
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -94,7 +94,7 @@ defmodule BDS.UI.Sidebar do
|
|||||||
)
|
)
|
||||||
|
|
||||||
"git" ->
|
"git" ->
|
||||||
git_view()
|
git_view(project_id)
|
||||||
|
|
||||||
"settings" ->
|
"settings" ->
|
||||||
settings_nav_view()
|
settings_nav_view()
|
||||||
@@ -139,13 +139,17 @@ defmodule BDS.UI.Sidebar do
|
|||||||
"import",
|
"import",
|
||||||
[]
|
[]
|
||||||
),
|
),
|
||||||
"git" => git_view(),
|
"git" => git_view(nil),
|
||||||
"settings" => settings_nav_view()
|
"settings" => settings_nav_view()
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp empty_view("posts"), do: build_posts_view([], %{}, false, empty_filter_params(), %{}, [], [], [])
|
defp empty_view("posts"),
|
||||||
defp empty_view("pages"), do: build_posts_view([], %{}, true, empty_filter_params(), %{}, [], [], [])
|
do: build_posts_view([], %{}, false, empty_filter_params(), %{}, [], [], [])
|
||||||
|
|
||||||
|
defp empty_view("pages"),
|
||||||
|
do: build_posts_view([], %{}, true, empty_filter_params(), %{}, [], [], [])
|
||||||
|
|
||||||
defp empty_view("media"), do: build_media_view([], empty_filter_params(), %{}, [], [], 0)
|
defp empty_view("media"), do: build_media_view([], empty_filter_params(), %{}, [], [], 0)
|
||||||
|
|
||||||
defp empty_view("scripts"),
|
defp empty_view("scripts"),
|
||||||
@@ -186,7 +190,7 @@ defmodule BDS.UI.Sidebar do
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
defp empty_view("git"), do: git_view()
|
defp empty_view("git"), do: git_view(nil)
|
||||||
defp empty_view("settings"), do: settings_nav_view()
|
defp empty_view("settings"), do: settings_nav_view()
|
||||||
|
|
||||||
defp empty_view(_other),
|
defp empty_view(_other),
|
||||||
@@ -563,7 +567,14 @@ defmodule BDS.UI.Sidebar do
|
|||||||
build_media_view(limited_media, filters, tag_colors, year_months, avail_tags, total_count)
|
build_media_view(limited_media, filters, tag_colors, year_months, avail_tags, total_count)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_media_view(limited_media, filters, tag_colors, year_month_counts, available_tags, total_count) do
|
defp build_media_view(
|
||||||
|
limited_media,
|
||||||
|
filters,
|
||||||
|
tag_colors,
|
||||||
|
year_month_counts,
|
||||||
|
available_tags,
|
||||||
|
total_count
|
||||||
|
) do
|
||||||
loaded_count = length(limited_media)
|
loaded_count = length(limited_media)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
@@ -779,24 +790,115 @@ defmodule BDS.UI.Sidebar do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp git_view do
|
@git_history_page_size 20
|
||||||
%{
|
|
||||||
|
defp git_view(project_id) do
|
||||||
|
base = %{
|
||||||
title: dgettext("ui", "Git"),
|
title: dgettext("ui", "Git"),
|
||||||
subtitle: dgettext("ui", "Working tree and history"),
|
subtitle: dgettext("ui", "Working tree and history"),
|
||||||
layout: "entity_list",
|
layout: "git",
|
||||||
empty_message: dgettext("ui", "No items"),
|
empty_message: dgettext("ui", "No items")
|
||||||
items: [
|
|
||||||
%{
|
|
||||||
id: "git-working-tree",
|
|
||||||
title: dgettext("ui", "Working tree"),
|
|
||||||
meta: dgettext("ui", "Working tree and history"),
|
|
||||||
route: "git_diff",
|
|
||||||
updated_at: nil
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
if git_repo?(project_id) do
|
||||||
|
Map.merge(base, active_git_view(project_id))
|
||||||
|
else
|
||||||
|
Map.merge(base, %{git_state: "not_a_repo", remote_url: nil})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp git_repo?(nil), do: false
|
||||||
|
|
||||||
|
defp git_repo?(project_id) when is_binary(project_id) do
|
||||||
|
case BDS.Projects.get_project(project_id) do
|
||||||
|
nil -> false
|
||||||
|
project -> File.dir?(Path.join(BDS.Projects.project_data_dir(project), ".git"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp active_git_view(project_id) do
|
||||||
|
repo =
|
||||||
|
case BDS.Git.repository(project_id) do
|
||||||
|
{:ok, repo} -> repo
|
||||||
|
_other -> %{current_branch: nil, remote_url: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
branch = repo[:current_branch]
|
||||||
|
|
||||||
|
remote =
|
||||||
|
case BDS.Git.remote_state(project_id) do
|
||||||
|
{:ok, state} -> state
|
||||||
|
_other -> %{upstream_branch: nil, ahead: 0, behind: 0}
|
||||||
|
end
|
||||||
|
|
||||||
|
status_files =
|
||||||
|
case BDS.Git.status(project_id) do
|
||||||
|
{:ok, %{files: files}} -> Enum.map(files, &git_status_file/1)
|
||||||
|
_other -> []
|
||||||
|
end
|
||||||
|
|
||||||
|
commits =
|
||||||
|
if is_binary(branch) do
|
||||||
|
case BDS.Git.history(project_id, branch) do
|
||||||
|
{:ok, %{commits: commits}} -> commits
|
||||||
|
_other -> []
|
||||||
|
end
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
git_state: "active",
|
||||||
|
branch: branch,
|
||||||
|
upstream: remote[:upstream_branch],
|
||||||
|
ahead: remote[:ahead] || 0,
|
||||||
|
behind: remote[:behind] || 0,
|
||||||
|
status_files: status_files,
|
||||||
|
history_entries:
|
||||||
|
commits |> Enum.take(@git_history_page_size) |> Enum.map(&git_history_entry/1),
|
||||||
|
has_more_history: length(commits) > @git_history_page_size,
|
||||||
|
remote_url: repo[:remote_url]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp git_status_file(%{status: status} = file) do
|
||||||
|
%{
|
||||||
|
path: Map.get(file, :path, ""),
|
||||||
|
status: to_string(status),
|
||||||
|
code: git_status_code(status),
|
||||||
|
label: git_status_label(status)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp git_status_code(:added), do: "A"
|
||||||
|
defp git_status_code(:deleted), do: "D"
|
||||||
|
defp git_status_code(:modified), do: "M"
|
||||||
|
defp git_status_code(:renamed), do: "R"
|
||||||
|
defp git_status_code(:untracked), do: "U"
|
||||||
|
defp git_status_code(_other), do: "M"
|
||||||
|
|
||||||
|
defp git_status_label(:added), do: dgettext("ui", "added")
|
||||||
|
defp git_status_label(:deleted), do: dgettext("ui", "deleted")
|
||||||
|
defp git_status_label(:modified), do: dgettext("ui", "modified")
|
||||||
|
defp git_status_label(:renamed), do: dgettext("ui", "renamed")
|
||||||
|
defp git_status_label(:untracked), do: dgettext("ui", "untracked")
|
||||||
|
defp git_status_label(_other), do: dgettext("ui", "modified")
|
||||||
|
|
||||||
|
defp git_history_entry(commit) do
|
||||||
|
%{
|
||||||
|
short_hash: commit |> Map.get(:hash, "") |> String.slice(0, 7),
|
||||||
|
subject: Map.get(commit, :subject),
|
||||||
|
author: Map.get(commit, :author),
|
||||||
|
date: Map.get(commit, :date),
|
||||||
|
sync_status: git_sync_status(get_in(commit, [:sync_status, :kind]))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp git_sync_status(:both), do: "synced"
|
||||||
|
defp git_sync_status(:local_only), do: "local_only"
|
||||||
|
defp git_sync_status(:remote_only), do: "remote_only"
|
||||||
|
defp git_sync_status(_other), do: "synced"
|
||||||
|
|
||||||
defp entity_list_view(title, subtitle, route, items) do
|
defp entity_list_view(title, subtitle, route, items) do
|
||||||
%{
|
%{
|
||||||
title: title,
|
title: title,
|
||||||
@@ -821,11 +923,13 @@ defmodule BDS.UI.Sidebar do
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
defp build_post_section(title, status, posts, translation_counts, published_meta?) do
|
defp build_post_section(title, status, posts, translation_counts, published_meta?) do
|
||||||
|
post_count = length(posts)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
id: Atom.to_string(status),
|
id: Atom.to_string(status),
|
||||||
title: title,
|
title: title,
|
||||||
status: Atom.to_string(status),
|
status: Atom.to_string(status),
|
||||||
count: length(posts),
|
count: post_count,
|
||||||
items:
|
items:
|
||||||
Enum.map(posts, fn post ->
|
Enum.map(posts, fn post ->
|
||||||
%{
|
%{
|
||||||
|
|||||||
8
mix.exs
8
mix.exs
@@ -33,7 +33,11 @@ defmodule BDS.MixProject do
|
|||||||
{:plug, "~> 1.18"},
|
{:plug, "~> 1.18"},
|
||||||
{:bandit, "~> 1.5"},
|
{:bandit, "~> 1.5"},
|
||||||
{:desktop, "~> 1.5"},
|
{:desktop, "~> 1.5"},
|
||||||
{:image, "~> 0.65"},
|
{:image, "~> 0.67"},
|
||||||
|
{:nx, "~> 0.10"},
|
||||||
|
{:exla, "~> 0.10"},
|
||||||
|
{:bumblebee, "~> 0.6.3"},
|
||||||
|
{:hnswlib, "~> 0.1.7"},
|
||||||
{:stemex, "~> 0.2.1"},
|
{:stemex, "~> 0.2.1"},
|
||||||
{:gettext, "~> 0.24"},
|
{:gettext, "~> 0.24"},
|
||||||
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
|
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
|
||||||
@@ -60,7 +64,7 @@ defmodule BDS.MixProject do
|
|||||||
env = Mix.env()
|
env = Mix.env()
|
||||||
|
|
||||||
[
|
[
|
||||||
plt_add_apps: [:mix, :inets, :ssl],
|
plt_add_apps: [:mix, :inets, :ssl, :nx, :exla, :bumblebee, :hnswlib],
|
||||||
paths: ["_build/#{env}/lib/bds/ebin"]
|
paths: ["_build/#{env}/lib/bds/ebin"]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|||||||
32
mix.lock
32
mix.lock
@@ -1,12 +1,16 @@
|
|||||||
%{
|
%{
|
||||||
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
|
"axon": {:hex, :axon, "0.7.0", "2e2c6d93b4afcfa812566b8922204fa022b60081e86ebd411df4db7ea30f5457", [:mix], [{:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}, {:kino_vega_lite, "~> 0.1.7", [hex: :kino_vega_lite, repo: "hexpm", optional: true]}, {:nx, "~> 0.9", [hex: :nx, repo: "hexpm", optional: false]}, {:polaris, "~> 0.1", [hex: :polaris, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1", [hex: :table_rex, repo: "hexpm", optional: true]}], "hexpm", "ee9857a143c9486597ceff434e6ca833dc1241be6158b01025b8217757ed1036"},
|
||||||
|
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
|
||||||
|
"bumblebee": {:hex, :bumblebee, "0.6.3", "c0028643c92de93258a9804da1d4d48797eaf7911b702464b3b3dd2cc7f938f1", [:mix], [{:axon, "~> 0.7.0", [hex: :axon, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.9.0 or ~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:nx_image, "~> 0.1.0", [hex: :nx_image, repo: "hexpm", optional: false]}, {:nx_signal, "~> 0.2.0", [hex: :nx_signal, repo: "hexpm", optional: false]}, {:progress_bar, "~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: false]}, {:safetensors, "~> 0.1.3", [hex: :safetensors, repo: "hexpm", optional: false]}, {:tokenizers, "~> 0.4", [hex: :tokenizers, repo: "hexpm", optional: false]}, {:unpickler, "~> 0.1.0", [hex: :unpickler, repo: "hexpm", optional: false]}, {:unzip, "~> 0.12.0", [hex: :unzip, repo: "hexpm", optional: false]}], "hexpm", "c619197787561f8e5fb2ffba269c341654accaec9d591999b7fddd55761dd079"},
|
||||||
|
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
|
||||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||||
"color": {:hex, :color, "0.12.0", "f59f9bb6452a460760d44116ec0c1cf86f9d7707c8756c01f83c6d8fe042ae67", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "1e17768919dad0bd44f48d0daf294d24bdd5a615bbfe0b4e01a51312203bd294"},
|
"color": {:hex, :color, "0.13.0", "068110e5397ac5d3c9f97658282e0f4ab9a32468be6d7a2a91a8804e67b228d7", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "de127946869931d418bac2d82dc29feae1a8f5f729f135922fbccf0059a58ab2"},
|
||||||
|
"complex": {:hex, :complex, "0.7.0", "695632ef9487517aa5d57edd1697801079d622414cb2e1a7cf538b1f9a50f205", [:mix], [], "hexpm", "0ee39c0803129f546e7f3f640da8f021c9e659402bf59da6f7f2c4848f068f8d"},
|
||||||
"date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"},
|
"date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"},
|
||||||
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
||||||
"dbus": {:hex, :dbus, "0.8.0", "7c800681f35d909c199265e55a8ee4aea9ebe4acccce77a0740f89f29cc57648", [:make], [], "hexpm", "a9784f2d9717ffa1f74169144a226c39633ac0d9c7fe8cb3594aeb89c827cca5"},
|
"dbus": {:hex, :dbus, "0.8.0", "7c800681f35d909c199265e55a8ee4aea9ebe4acccce77a0740f89f29cc57648", [:make], [], "hexpm", "a9784f2d9717ffa1f74169144a226c39633ac0d9c7fe8cb3594aeb89c827cca5"},
|
||||||
"debouncer": {:hex, :debouncer, "0.1.13", "af5906b231c196943ac8386b5b5f45a2f36d54a8bcd7e1b29eef2671de33d287", [:mix], [], "hexpm", "a14f57420c7d4a287f8f08e715fc8759b5d28dcd1032f9585d57c45d22123382"},
|
"debouncer": {:hex, :debouncer, "0.1.13", "af5906b231c196943ac8386b5b5f45a2f36d54a8bcd7e1b29eef2671de33d287", [:mix], [], "hexpm", "a14f57420c7d4a287f8f08e715fc8759b5d28dcd1032f9585d57c45d22123382"},
|
||||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
"decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
|
||||||
"desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"},
|
"desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"},
|
||||||
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
|
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
|
||||||
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
|
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
|
||||||
@@ -19,15 +23,17 @@
|
|||||||
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
|
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
|
||||||
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
|
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
|
||||||
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
|
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
|
||||||
|
"exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"},
|
||||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||||
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
|
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
|
||||||
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
|
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
|
||||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||||
|
"hnswlib": {:hex, :hnswlib, "0.1.7", "784afdbfbc9af53e64d4b6da3f685c07039e472636a98fa954ffae5292ad6cc4", [:make, :mix], [{:cc_precompiler, "~> 0.1.0", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "fb43bb675facc8bb1ef0f4f8fec92479fc23317ed0f35c7160b2f95aff3e4742"},
|
||||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
|
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
|
||||||
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"},
|
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"},
|
||||||
"image": {:hex, :image, "0.65.0", "44908233a1a0dcdbb6ae873ec09fd9ae533d1840d300d8b0b1b186d586b935e6", [:mix], [{:color, "~> 0.4", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "0.11.0", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.11.0", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "d2060e08d0f42564f49de1ea97a82a5d237f9ac91edb141dece51f1238dd8b4a"},
|
"image": {:hex, :image, "0.67.0", "886325f45bd39f3d705d32f223680163f3eaba142526d34f7f871c2232577e64", [:mix], [{:color, "~> 0.13", [hex: :color, repo: "hexpm", optional: false]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.10", [hex: :exla, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.10", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}, {:xav, "~> 0.10", [hex: :xav, repo: "hexpm", optional: true]}], "hexpm", "401c3e13137af8932eee377ad8bc8a8ae1a8343894266543e8bedd36b414c999"},
|
||||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
||||||
"kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"},
|
"kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"},
|
||||||
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
|
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
|
||||||
"liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"},
|
"liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"},
|
||||||
@@ -35,23 +41,35 @@
|
|||||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
|
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
|
||||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||||
|
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||||
|
"nx": {:hex, :nx, "0.10.0", "128e4a094cb790f663e20e1334b127c1f2a4df54edfb8b13c22757ec33133b4f", [:mix], [{:complex, "~> 0.6", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3db8892c124aeee091df0e6fbf8e5bf1b81f502eb0d4f5ba63e6378ebcae7da4"},
|
||||||
|
"nx_image": {:hex, :nx_image, "0.1.2", "0c6e3453c1dc30fc80c723a54861204304cebc8a89ed3b806b972c73ee5d119d", [:mix], [{:nx, "~> 0.4", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "9161863c42405ddccb6dbbbeae078ad23e30201509cc804b3b3a7c9e98764b81"},
|
||||||
|
"nx_signal": {:hex, :nx_signal, "0.2.0", "e1ca0318877b17c81ce8906329f5125f1e2361e4c4235a5baac8a95ee88ea98e", [:mix], [{:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "7247e5e18a177a59c4cb5355952900c62fdeadeb2bad02a9a34237b68744e2bb"},
|
||||||
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
|
"oncrash": {:hex, :oncrash, "0.1.0", "9cf4ae8eba4ea250b579470172c5e9b8c75418b2264de7dbcf42e408d62e30fb", [:mix], [], "hexpm", "6968e775491cd857f9b6ff940bf2574fd1c2fab84fa7e14d5f56c39174c00018"},
|
||||||
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
|
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
|
||||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
|
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
|
||||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||||
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
"plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
|
||||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||||
|
"polaris": {:hex, :polaris, "0.1.0", "dca61b18e3e801ecdae6ac9f0eca5f19792b44a5cb4b8d63db50fc40fc038d22", [:mix], [{:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "13ef2b166650e533cb24b10e2f3b8ab4f2f449ba4d63156e8c569527f206e2c2"},
|
||||||
|
"progress_bar": {:hex, :progress_bar, "3.0.0", "f54ff038c2ac540cfbb4c2bfe97c75e7116ead044f3c2b10c9f212452194b5cd", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "6981c2b25ab24aecc91a2dc46623658e1399c21a2ae24db986b90d678530f2b7"},
|
||||||
"rustler": {:hex, :rustler, "0.32.1", "f4cf5a39f9e85d182c0a3f75fa15b5d0add6542ab0bf9ceac6b4023109ebd3fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "b96be75526784f86f6587f051bc8d6f4eaff23d6e0f88dbcfe4d5871f52946f7"},
|
"rustler": {:hex, :rustler, "0.32.1", "f4cf5a39f9e85d182c0a3f75fa15b5d0add6542ab0bf9ceac6b4023109ebd3fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "b96be75526784f86f6587f051bc8d6f4eaff23d6e0f88dbcfe4d5871f52946f7"},
|
||||||
|
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
|
||||||
|
"safetensors": {:hex, :safetensors, "0.1.3", "7ff3c22391e213289c713898481d492c9c28a49ab1d0705b72630fb8360426b2", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "fe50b53ea59fde4e723dd1a2e31cfdc6013e69343afac84c6be86d6d7c562c14"},
|
||||||
"saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"},
|
"saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"},
|
||||||
"stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"},
|
"stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"},
|
||||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||||
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
|
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
|
||||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||||
|
"tokenizers": {:hex, :tokenizers, "0.5.1", "b0975d92b4ee5b18e8f47b5d65b9d5f1e583d9130189b1a2620401af4e7d4b35", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5f08d97cc7f2ed3d71d370d68120da6d3de010948ccf676c9c0eb591ba4bacc9"},
|
||||||
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
|
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
|
||||||
|
"unpickler": {:hex, :unpickler, "0.1.0", "c2262c0819e6985b761e7107546cef96a485f401816be5304a65fdd200d5bd6a", [:mix], [], "hexpm", "e2b3f61e62406187ac52afead8a63bfb4e49394028993f3c4c42712743cab79e"},
|
||||||
|
"unzip": {:hex, :unzip, "0.12.0", "beed92238724732418b41eba77dcb7f51e235b707406c05b1732a3052d1c0f36", [:mix], [], "hexpm", "95655b72db368e5a84951f0bed586ac053b55ee3815fd96062fce10ce4fc998d"},
|
||||||
"vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"},
|
"vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"},
|
||||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||||
|
"xla": {:hex, :xla, "0.9.1", "cca0040ff94902764007a118871bfc667f1a0085d4a5074533a47d6b58bec61e", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "eb5e443ae5391b1953f253e051f2307bea183b59acee138053a9300779930daf"},
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}"></div>
|
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}" data-search-no-results="{{ labels.search_no_results }}"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}"></div>
|
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}" data-search-no-results="{{ labels.search_no_results }}"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,156 +1,161 @@
|
|||||||
#: lib/bds/rendering/labels.ex:17
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:241
|
#: lib/bds/ui/sidebar.ex:284
|
||||||
#: lib/bds/ui/sidebar.ex:316
|
#: lib/bds/ui/sidebar.ex:578
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archiv"
|
msgstr "Archiv"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:52
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr "Apr."
|
msgstr "Apr."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:24
|
#: lib/bds/rendering/labels.ex:25
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr "Archiv"
|
msgstr "Archiv"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:68
|
#: lib/bds/rendering/labels.ex:71
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr "Aug."
|
msgstr "Aug."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:15
|
#: lib/bds/rendering/labels.ex:16
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Backlinks"
|
msgid "Backlinks"
|
||||||
msgstr "Rückverweise"
|
msgstr "Rückverweise"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:23
|
#: lib/bds/rendering/labels.ex:24
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Calendar data could not be loaded."
|
msgid "Calendar data could not be loaded."
|
||||||
msgstr "Kalenderdaten konnten nicht geladen werden."
|
msgstr "Kalenderdaten konnten nicht geladen werden."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:25
|
#: lib/bds/rendering/labels.ex:26
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr "Kalender schließen"
|
msgstr "Kalender schließen"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:84
|
#: lib/bds/rendering/labels.ex:87
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr "Dezember"
|
msgstr "Dezember"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:44
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr "Februar"
|
msgstr "Februar"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:40
|
#: lib/bds/rendering/labels.ex:43
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr "Januar"
|
msgstr "Januar"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:64
|
#: lib/bds/rendering/labels.ex:67
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr "Juli"
|
msgstr "Juli"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:60
|
#: lib/bds/rendering/labels.ex:63
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr "Juni"
|
msgstr "Juni"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:26
|
#: lib/bds/rendering/labels.ex:27
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Sprache"
|
msgstr "Sprache"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:16
|
#: lib/bds/rendering/labels.ex:17
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Linked from"
|
msgid "Linked from"
|
||||||
msgstr "Verlinkt von"
|
msgstr "Verlinkt von"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:22
|
#: lib/bds/rendering/labels.ex:23
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr "Kalender wird geladen …"
|
msgstr "Kalender wird geladen …"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:48
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr "März"
|
msgstr "März"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:56
|
#: lib/bds/rendering/labels.ex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr "Mai"
|
msgstr "Mai"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:80
|
#: lib/bds/rendering/labels.ex:83
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr "Nov."
|
msgstr "Nov."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:76
|
#: lib/bds/rendering/labels.ex:79
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr "Oktober"
|
msgstr "Oktober"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:21
|
#: lib/bds/rendering/labels.ex:22
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Open calendar"
|
msgid "Open calendar"
|
||||||
msgstr "Kalender öffnen"
|
msgstr "Kalender öffnen"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:19
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Pagination"
|
msgid "Pagination"
|
||||||
msgstr "Seitennummerierung"
|
msgstr "Seitennummerierung"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:28
|
#: lib/bds/rendering/labels.ex:29
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr "Suchen..."
|
msgstr "Suchen..."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:72
|
#: lib/bds/rendering/labels.ex:75
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr "Sept."
|
msgstr "Sept."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:27
|
#: lib/bds/rendering/labels.ex:28
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Site search"
|
msgid "Site search"
|
||||||
msgstr "Seitensuche"
|
msgstr "Seitensuche"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:14
|
#: lib/bds/rendering/labels.ex:15
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Taxonomy"
|
msgid "Taxonomy"
|
||||||
msgstr "Taxonomie"
|
msgstr "Taxonomie"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:19
|
#: lib/bds/rendering/labels.ex:20
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "newer"
|
msgid "newer"
|
||||||
msgstr "neuer"
|
msgstr "neuer"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:20
|
#: lib/bds/rendering/labels.ex:21
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "older"
|
msgid "older"
|
||||||
msgstr "älter"
|
msgstr "älter"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:30
|
#: lib/bds/rendering/labels.ex:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Back to preview home"
|
msgid "Back to preview home"
|
||||||
msgstr "Zurück zur Vorschau-Startseite"
|
msgstr "Zurück zur Vorschau-Startseite"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:29
|
#: lib/bds/rendering/labels.ex:31
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "The requested preview page could not be found."
|
msgid "The requested preview page could not be found."
|
||||||
msgstr "Die angeforderte Vorschauseite konnte nicht gefunden werden."
|
msgstr "Die angeforderte Vorschauseite konnte nicht gefunden werden."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:32
|
#: lib/bds/rendering/labels.ex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Vimeo video"
|
msgid "Vimeo video"
|
||||||
msgstr "Vimeo-Video"
|
msgstr "Vimeo-Video"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:31
|
#: lib/bds/rendering/labels.ex:33
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr "YouTube-Video"
|
msgstr "YouTube-Video"
|
||||||
|
|
||||||
|
#: lib/bds/rendering/labels.ex:30
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No results found"
|
||||||
|
msgstr "Keine Ergebnisse gefunden"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,156 +1,161 @@
|
|||||||
#: lib/bds/rendering/labels.ex:17
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:241
|
#: lib/bds/ui/sidebar.ex:284
|
||||||
#: lib/bds/ui/sidebar.ex:316
|
#: lib/bds/ui/sidebar.ex:578
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:52
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:24
|
#: lib/bds/rendering/labels.ex:25
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:68
|
#: lib/bds/rendering/labels.ex:71
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:15
|
#: lib/bds/rendering/labels.ex:16
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Backlinks"
|
msgid "Backlinks"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:23
|
#: lib/bds/rendering/labels.ex:24
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Calendar data could not be loaded."
|
msgid "Calendar data could not be loaded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:25
|
#: lib/bds/rendering/labels.ex:26
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:84
|
#: lib/bds/rendering/labels.ex:87
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:44
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:40
|
#: lib/bds/rendering/labels.ex:43
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:64
|
#: lib/bds/rendering/labels.ex:67
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:60
|
#: lib/bds/rendering/labels.ex:63
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:26
|
#: lib/bds/rendering/labels.ex:27
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:16
|
#: lib/bds/rendering/labels.ex:17
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Linked from"
|
msgid "Linked from"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:22
|
#: lib/bds/rendering/labels.ex:23
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:48
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:56
|
#: lib/bds/rendering/labels.ex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:80
|
#: lib/bds/rendering/labels.ex:83
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:76
|
#: lib/bds/rendering/labels.ex:79
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:21
|
#: lib/bds/rendering/labels.ex:22
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Open calendar"
|
msgid "Open calendar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:19
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Pagination"
|
msgid "Pagination"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:28
|
#: lib/bds/rendering/labels.ex:29
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:72
|
#: lib/bds/rendering/labels.ex:75
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:27
|
#: lib/bds/rendering/labels.ex:28
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Site search"
|
msgid "Site search"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:14
|
#: lib/bds/rendering/labels.ex:15
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Taxonomy"
|
msgid "Taxonomy"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:19
|
#: lib/bds/rendering/labels.ex:20
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "newer"
|
msgid "newer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:20
|
#: lib/bds/rendering/labels.ex:21
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "older"
|
msgid "older"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:30
|
#: lib/bds/rendering/labels.ex:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Back to preview home"
|
msgid "Back to preview home"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:29
|
#: lib/bds/rendering/labels.ex:31
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "The requested preview page could not be found."
|
msgid "The requested preview page could not be found."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:32
|
#: lib/bds/rendering/labels.ex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Vimeo video"
|
msgid "Vimeo video"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:31
|
#: lib/bds/rendering/labels.ex:33
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/bds/rendering/labels.ex:30
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No results found"
|
||||||
|
msgstr ""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,156 +1,161 @@
|
|||||||
#: lib/bds/rendering/labels.ex:17
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:241
|
#: lib/bds/ui/sidebar.ex:284
|
||||||
#: lib/bds/ui/sidebar.ex:316
|
#: lib/bds/ui/sidebar.ex:578
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archivo"
|
msgstr "Archivo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:52
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr "abril"
|
msgstr "abril"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:24
|
#: lib/bds/rendering/labels.ex:25
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr "Archivo"
|
msgstr "Archivo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:68
|
#: lib/bds/rendering/labels.ex:71
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr "agosto"
|
msgstr "agosto"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:15
|
#: lib/bds/rendering/labels.ex:16
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Backlinks"
|
msgid "Backlinks"
|
||||||
msgstr "Retroenlaces"
|
msgstr "Retroenlaces"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:23
|
#: lib/bds/rendering/labels.ex:24
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Calendar data could not be loaded."
|
msgid "Calendar data could not be loaded."
|
||||||
msgstr "No se pudieron cargar los datos del calendario."
|
msgstr "No se pudieron cargar los datos del calendario."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:25
|
#: lib/bds/rendering/labels.ex:26
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr "Cerrar calendario"
|
msgstr "Cerrar calendario"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:84
|
#: lib/bds/rendering/labels.ex:87
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr "diciembre"
|
msgstr "diciembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:44
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr "febrero"
|
msgstr "febrero"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:40
|
#: lib/bds/rendering/labels.ex:43
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr "enero"
|
msgstr "enero"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:64
|
#: lib/bds/rendering/labels.ex:67
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr "julio"
|
msgstr "julio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:60
|
#: lib/bds/rendering/labels.ex:63
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr "junio"
|
msgstr "junio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:26
|
#: lib/bds/rendering/labels.ex:27
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Idioma"
|
msgstr "Idioma"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:16
|
#: lib/bds/rendering/labels.ex:17
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Linked from"
|
msgid "Linked from"
|
||||||
msgstr "Enlazado desde"
|
msgstr "Enlazado desde"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:22
|
#: lib/bds/rendering/labels.ex:23
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr "Cargando calendario…"
|
msgstr "Cargando calendario…"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:48
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr "marzo"
|
msgstr "marzo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:56
|
#: lib/bds/rendering/labels.ex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr "mayo"
|
msgstr "mayo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:80
|
#: lib/bds/rendering/labels.ex:83
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr "noviembre"
|
msgstr "noviembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:76
|
#: lib/bds/rendering/labels.ex:79
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr "octubre"
|
msgstr "octubre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:21
|
#: lib/bds/rendering/labels.ex:22
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Open calendar"
|
msgid "Open calendar"
|
||||||
msgstr "Abrir calendario"
|
msgstr "Abrir calendario"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:19
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Pagination"
|
msgid "Pagination"
|
||||||
msgstr "Paginación"
|
msgstr "Paginación"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:28
|
#: lib/bds/rendering/labels.ex:29
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr "Buscar..."
|
msgstr "Buscar..."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:72
|
#: lib/bds/rendering/labels.ex:75
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr "septiembre"
|
msgstr "septiembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:27
|
#: lib/bds/rendering/labels.ex:28
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Site search"
|
msgid "Site search"
|
||||||
msgstr "Buscar en el sitio"
|
msgstr "Buscar en el sitio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:14
|
#: lib/bds/rendering/labels.ex:15
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Taxonomy"
|
msgid "Taxonomy"
|
||||||
msgstr "Taxonomía"
|
msgstr "Taxonomía"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:19
|
#: lib/bds/rendering/labels.ex:20
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "newer"
|
msgid "newer"
|
||||||
msgstr "más reciente"
|
msgstr "más reciente"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:20
|
#: lib/bds/rendering/labels.ex:21
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "older"
|
msgid "older"
|
||||||
msgstr "más antiguo"
|
msgstr "más antiguo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:30
|
#: lib/bds/rendering/labels.ex:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Back to preview home"
|
msgid "Back to preview home"
|
||||||
msgstr "Volver al inicio de vista previa"
|
msgstr "Volver al inicio de vista previa"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:29
|
#: lib/bds/rendering/labels.ex:31
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "The requested preview page could not be found."
|
msgid "The requested preview page could not be found."
|
||||||
msgstr "No se pudo encontrar la página de vista previa solicitada."
|
msgstr "No se pudo encontrar la página de vista previa solicitada."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:32
|
#: lib/bds/rendering/labels.ex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Vimeo video"
|
msgid "Vimeo video"
|
||||||
msgstr "Vídeo de Vimeo"
|
msgstr "Vídeo de Vimeo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:31
|
#: lib/bds/rendering/labels.ex:33
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr "Vídeo de YouTube"
|
msgstr "Vídeo de YouTube"
|
||||||
|
|
||||||
|
#: lib/bds/rendering/labels.ex:30
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No results found"
|
||||||
|
msgstr "No se encontraron resultados"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,156 +1,161 @@
|
|||||||
#: lib/bds/rendering/labels.ex:17
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:241
|
#: lib/bds/ui/sidebar.ex:284
|
||||||
#: lib/bds/ui/sidebar.ex:316
|
#: lib/bds/ui/sidebar.ex:578
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archives"
|
msgstr "Archives"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:52
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr "avril"
|
msgstr "avril"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:24
|
#: lib/bds/rendering/labels.ex:25
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr "Archives"
|
msgstr "Archives"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:68
|
#: lib/bds/rendering/labels.ex:71
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr "août"
|
msgstr "août"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:15
|
#: lib/bds/rendering/labels.ex:16
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Backlinks"
|
msgid "Backlinks"
|
||||||
msgstr "Rétroliens"
|
msgstr "Rétroliens"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:23
|
#: lib/bds/rendering/labels.ex:24
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Calendar data could not be loaded."
|
msgid "Calendar data could not be loaded."
|
||||||
msgstr "Impossible de charger les données du calendrier."
|
msgstr "Impossible de charger les données du calendrier."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:25
|
#: lib/bds/rendering/labels.ex:26
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr "Fermer le calendrier"
|
msgstr "Fermer le calendrier"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:84
|
#: lib/bds/rendering/labels.ex:87
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr "décembre"
|
msgstr "décembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:44
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr "février"
|
msgstr "février"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:40
|
#: lib/bds/rendering/labels.ex:43
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr "janvier"
|
msgstr "janvier"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:64
|
#: lib/bds/rendering/labels.ex:67
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr "juillet"
|
msgstr "juillet"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:60
|
#: lib/bds/rendering/labels.ex:63
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr "juin"
|
msgstr "juin"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:26
|
#: lib/bds/rendering/labels.ex:27
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Langue"
|
msgstr "Langue"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:16
|
#: lib/bds/rendering/labels.ex:17
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Linked from"
|
msgid "Linked from"
|
||||||
msgstr "Lié depuis"
|
msgstr "Lié depuis"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:22
|
#: lib/bds/rendering/labels.ex:23
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr "Chargement du calendrier…"
|
msgstr "Chargement du calendrier…"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:48
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr "mars"
|
msgstr "mars"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:56
|
#: lib/bds/rendering/labels.ex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr "mai"
|
msgstr "mai"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:80
|
#: lib/bds/rendering/labels.ex:83
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr "novembre"
|
msgstr "novembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:76
|
#: lib/bds/rendering/labels.ex:79
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr "octobre"
|
msgstr "octobre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:21
|
#: lib/bds/rendering/labels.ex:22
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Open calendar"
|
msgid "Open calendar"
|
||||||
msgstr "Ouvrir le calendrier"
|
msgstr "Ouvrir le calendrier"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:19
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Pagination"
|
msgid "Pagination"
|
||||||
msgstr "Navigation paginée"
|
msgstr "Navigation paginée"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:28
|
#: lib/bds/rendering/labels.ex:29
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr "Rechercher..."
|
msgstr "Rechercher..."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:72
|
#: lib/bds/rendering/labels.ex:75
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr "septembre"
|
msgstr "septembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:27
|
#: lib/bds/rendering/labels.ex:28
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Site search"
|
msgid "Site search"
|
||||||
msgstr "Recherche du site"
|
msgstr "Recherche du site"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:14
|
#: lib/bds/rendering/labels.ex:15
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Taxonomy"
|
msgid "Taxonomy"
|
||||||
msgstr "Taxonomie"
|
msgstr "Taxonomie"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:19
|
#: lib/bds/rendering/labels.ex:20
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "newer"
|
msgid "newer"
|
||||||
msgstr "plus récent"
|
msgstr "plus récent"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:20
|
#: lib/bds/rendering/labels.ex:21
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "older"
|
msgid "older"
|
||||||
msgstr "plus ancien"
|
msgstr "plus ancien"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:30
|
#: lib/bds/rendering/labels.ex:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Back to preview home"
|
msgid "Back to preview home"
|
||||||
msgstr "Retour à l’accueil de l’aperçu"
|
msgstr "Retour à l’accueil de l’aperçu"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:29
|
#: lib/bds/rendering/labels.ex:31
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "The requested preview page could not be found."
|
msgid "The requested preview page could not be found."
|
||||||
msgstr "La page d’aperçu demandée est introuvable."
|
msgstr "La page d’aperçu demandée est introuvable."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:32
|
#: lib/bds/rendering/labels.ex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Vimeo video"
|
msgid "Vimeo video"
|
||||||
msgstr "Vidéo Vimeo"
|
msgstr "Vidéo Vimeo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:31
|
#: lib/bds/rendering/labels.ex:33
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr "Vidéo YouTube"
|
msgstr "Vidéo YouTube"
|
||||||
|
|
||||||
|
#: lib/bds/rendering/labels.ex:30
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No results found"
|
||||||
|
msgstr "Aucun résultat trouvé"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,156 +1,161 @@
|
|||||||
#: lib/bds/rendering/labels.ex:17
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:241
|
#: lib/bds/ui/sidebar.ex:284
|
||||||
#: lib/bds/ui/sidebar.ex:316
|
#: lib/bds/ui/sidebar.ex:578
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr "Archivio"
|
msgstr "Archivio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:52
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr "aprile"
|
msgstr "aprile"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:24
|
#: lib/bds/rendering/labels.ex:25
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr "Archivio"
|
msgstr "Archivio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:68
|
#: lib/bds/rendering/labels.ex:71
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr "agosto"
|
msgstr "agosto"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:15
|
#: lib/bds/rendering/labels.ex:16
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Backlinks"
|
msgid "Backlinks"
|
||||||
msgstr "Retrocollegamenti"
|
msgstr "Retrocollegamenti"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:23
|
#: lib/bds/rendering/labels.ex:24
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Calendar data could not be loaded."
|
msgid "Calendar data could not be loaded."
|
||||||
msgstr "Impossibile caricare i dati del calendario."
|
msgstr "Impossibile caricare i dati del calendario."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:25
|
#: lib/bds/rendering/labels.ex:26
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr "Chiudi calendario"
|
msgstr "Chiudi calendario"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:84
|
#: lib/bds/rendering/labels.ex:87
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr "dicembre"
|
msgstr "dicembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:44
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr "febbraio"
|
msgstr "febbraio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:40
|
#: lib/bds/rendering/labels.ex:43
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr "gennaio"
|
msgstr "gennaio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:64
|
#: lib/bds/rendering/labels.ex:67
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr "luglio"
|
msgstr "luglio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:60
|
#: lib/bds/rendering/labels.ex:63
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr "giugno"
|
msgstr "giugno"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:26
|
#: lib/bds/rendering/labels.ex:27
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Lingua"
|
msgstr "Lingua"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:16
|
#: lib/bds/rendering/labels.ex:17
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Linked from"
|
msgid "Linked from"
|
||||||
msgstr "Collegato da"
|
msgstr "Collegato da"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:22
|
#: lib/bds/rendering/labels.ex:23
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr "Caricamento calendario…"
|
msgstr "Caricamento calendario…"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:48
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr "marzo"
|
msgstr "marzo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:56
|
#: lib/bds/rendering/labels.ex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr "maggio"
|
msgstr "maggio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:80
|
#: lib/bds/rendering/labels.ex:83
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr "novembre"
|
msgstr "novembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:76
|
#: lib/bds/rendering/labels.ex:79
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr "ottobre"
|
msgstr "ottobre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:21
|
#: lib/bds/rendering/labels.ex:22
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Open calendar"
|
msgid "Open calendar"
|
||||||
msgstr "Apri calendario"
|
msgstr "Apri calendario"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:19
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Pagination"
|
msgid "Pagination"
|
||||||
msgstr "Paginazione"
|
msgstr "Paginazione"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:28
|
#: lib/bds/rendering/labels.ex:29
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr "Cerca..."
|
msgstr "Cerca..."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:72
|
#: lib/bds/rendering/labels.ex:75
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr "settembre"
|
msgstr "settembre"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:27
|
#: lib/bds/rendering/labels.ex:28
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Site search"
|
msgid "Site search"
|
||||||
msgstr "Ricerca nel sito"
|
msgstr "Ricerca nel sito"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:14
|
#: lib/bds/rendering/labels.ex:15
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Taxonomy"
|
msgid "Taxonomy"
|
||||||
msgstr "Tassonomia"
|
msgstr "Tassonomia"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:19
|
#: lib/bds/rendering/labels.ex:20
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "newer"
|
msgid "newer"
|
||||||
msgstr "più recente"
|
msgstr "più recente"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:20
|
#: lib/bds/rendering/labels.ex:21
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "older"
|
msgid "older"
|
||||||
msgstr "più vecchio"
|
msgstr "più vecchio"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:30
|
#: lib/bds/rendering/labels.ex:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Back to preview home"
|
msgid "Back to preview home"
|
||||||
msgstr "Torna alla home di anteprima"
|
msgstr "Torna alla home di anteprima"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:29
|
#: lib/bds/rendering/labels.ex:31
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "The requested preview page could not be found."
|
msgid "The requested preview page could not be found."
|
||||||
msgstr "La pagina di anteprima richiesta non è stata trovata."
|
msgstr "La pagina di anteprima richiesta non è stata trovata."
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:32
|
#: lib/bds/rendering/labels.ex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Vimeo video"
|
msgid "Vimeo video"
|
||||||
msgstr "Video Vimeo"
|
msgstr "Video Vimeo"
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:31
|
#: lib/bds/rendering/labels.ex:33
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr "Video YouTube"
|
msgstr "Video YouTube"
|
||||||
|
|
||||||
|
#: lib/bds/rendering/labels.ex:30
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No results found"
|
||||||
|
msgstr "Nessun risultato trovato"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,159 +11,164 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:17
|
#: lib/bds/rendering/labels.ex:18
|
||||||
#: lib/bds/ui/sidebar.ex:241
|
#: lib/bds/ui/sidebar.ex:284
|
||||||
#: lib/bds/ui/sidebar.ex:316
|
#: lib/bds/ui/sidebar.ex:578
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive"
|
msgid "Archive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:52
|
#: lib/bds/rendering/labels.ex:55
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "April"
|
msgid "April"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:24
|
#: lib/bds/rendering/labels.ex:25
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Archive calendar"
|
msgid "Archive calendar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:68
|
#: lib/bds/rendering/labels.ex:71
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "August"
|
msgid "August"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:15
|
#: lib/bds/rendering/labels.ex:16
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Backlinks"
|
msgid "Backlinks"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:23
|
#: lib/bds/rendering/labels.ex:24
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Calendar data could not be loaded."
|
msgid "Calendar data could not be loaded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:25
|
#: lib/bds/rendering/labels.ex:26
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Close calendar"
|
msgid "Close calendar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:84
|
#: lib/bds/rendering/labels.ex:87
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "December"
|
msgid "December"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:44
|
#: lib/bds/rendering/labels.ex:47
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "February"
|
msgid "February"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:40
|
#: lib/bds/rendering/labels.ex:43
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:64
|
#: lib/bds/rendering/labels.ex:67
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "July"
|
msgid "July"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:60
|
#: lib/bds/rendering/labels.ex:63
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "June"
|
msgid "June"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:26
|
#: lib/bds/rendering/labels.ex:27
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:16
|
#: lib/bds/rendering/labels.ex:17
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Linked from"
|
msgid "Linked from"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:22
|
#: lib/bds/rendering/labels.ex:23
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Loading calendar…"
|
msgid "Loading calendar…"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:48
|
#: lib/bds/rendering/labels.ex:51
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "March"
|
msgid "March"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:56
|
#: lib/bds/rendering/labels.ex:59
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "May"
|
msgid "May"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:80
|
#: lib/bds/rendering/labels.ex:83
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "November"
|
msgid "November"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:76
|
#: lib/bds/rendering/labels.ex:79
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "October"
|
msgid "October"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:21
|
#: lib/bds/rendering/labels.ex:22
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Open calendar"
|
msgid "Open calendar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:18
|
#: lib/bds/rendering/labels.ex:19
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Pagination"
|
msgid "Pagination"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:28
|
#: lib/bds/rendering/labels.ex:29
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Search..."
|
msgid "Search..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:72
|
#: lib/bds/rendering/labels.ex:75
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:27
|
#: lib/bds/rendering/labels.ex:28
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Site search"
|
msgid "Site search"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:14
|
#: lib/bds/rendering/labels.ex:15
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Taxonomy"
|
msgid "Taxonomy"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:19
|
#: lib/bds/rendering/labels.ex:20
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "newer"
|
msgid "newer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:20
|
#: lib/bds/rendering/labels.ex:21
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "older"
|
msgid "older"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:30
|
#: lib/bds/rendering/labels.ex:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Back to preview home"
|
msgid "Back to preview home"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:29
|
#: lib/bds/rendering/labels.ex:31
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "The requested preview page could not be found."
|
msgid "The requested preview page could not be found."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:32
|
#: lib/bds/rendering/labels.ex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Vimeo video"
|
msgid "Vimeo video"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/bds/rendering/labels.ex:31
|
#: lib/bds/rendering/labels.ex:33
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/bds/rendering/labels.ex:30
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No results found"
|
||||||
|
msgstr ""
|
||||||
|
|||||||
1690
priv/gettext/ui.pot
1690
priv/gettext/ui.pot
File diff suppressed because it is too large
Load Diff
65
priv/preview_assets/assets/pagefind-ui.css
Normal file
65
priv/preview_assets/assets/pagefind-ui.css
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/* Styling for the self-contained PagefindUI search widget. */
|
||||||
|
.pagefind-ui {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagefind-ui__form {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagefind-ui__search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--pico-form-element-border-color, #ccc);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagefind-ui__results {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagefind-ui__message {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagefind-ui__result-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagefind-ui__result {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--pico-muted-border-color, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagefind-ui__result:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagefind-ui__result-link {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagefind-ui__result-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagefind-ui__result-excerpt {
|
||||||
|
margin: 0.25rem 0 0 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagefind-ui__result-excerpt mark {
|
||||||
|
background: var(--pico-mark-background-color, #ffe08a);
|
||||||
|
color: inherit;
|
||||||
|
padding: 0 0.1em;
|
||||||
|
border-radius: 0.15em;
|
||||||
|
}
|
||||||
272
priv/preview_assets/assets/pagefind-ui.js
Normal file
272
priv/preview_assets/assets/pagefind-ui.js
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/*
|
||||||
|
* Self-contained client-side search UI for generated blog output.
|
||||||
|
*
|
||||||
|
* Exposes a global `PagefindUI` constructor that the bundled
|
||||||
|
* `search-runtime.js` instantiates. It fetches the per-language fragment
|
||||||
|
* index (`index.json`) co-located with this script, performs full-text
|
||||||
|
* matching over the indexed post fragments, and renders ranked results.
|
||||||
|
*
|
||||||
|
* No external/CDN dependencies — everything needed ships in this file.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Resolve the sibling index.json relative to this script's own URL so the
|
||||||
|
// same bundle works for every language directory (e.g. /pagefind/,
|
||||||
|
// /de/pagefind/) without baking the path in at generation time.
|
||||||
|
var scriptSrc = (document.currentScript && document.currentScript.src) || "";
|
||||||
|
|
||||||
|
function resolveIndexUrl(src) {
|
||||||
|
try {
|
||||||
|
return new URL("index.json", src).href;
|
||||||
|
} catch (err) {
|
||||||
|
return "index.json";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(value) {
|
||||||
|
return (value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
|
.filter(function (token) {
|
||||||
|
return token.length > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function PagefindUI(options) {
|
||||||
|
options = options || {};
|
||||||
|
this.element =
|
||||||
|
typeof options.element === "string"
|
||||||
|
? document.querySelector(options.element)
|
||||||
|
: options.element;
|
||||||
|
|
||||||
|
if (!this.element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var translations = options.translations || {};
|
||||||
|
this.placeholder = translations.placeholder || "Search";
|
||||||
|
this.zeroResults = translations.zero_results || "No results found";
|
||||||
|
this.indexUrl = options.indexUrl || resolveIndexUrl(scriptSrc);
|
||||||
|
this.pages = null;
|
||||||
|
this.loadPromise = null;
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
PagefindUI.prototype.render = function () {
|
||||||
|
var self = this;
|
||||||
|
this.element.classList.add("pagefind-ui");
|
||||||
|
this.element.innerHTML = "";
|
||||||
|
|
||||||
|
var form = document.createElement("form");
|
||||||
|
form.className = "pagefind-ui__form";
|
||||||
|
form.setAttribute("role", "search");
|
||||||
|
form.addEventListener("submit", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
var input = document.createElement("input");
|
||||||
|
input.type = "search";
|
||||||
|
input.className = "pagefind-ui__search-input";
|
||||||
|
input.setAttribute("autocomplete", "off");
|
||||||
|
input.placeholder = this.placeholder;
|
||||||
|
input.setAttribute("aria-label", this.placeholder);
|
||||||
|
|
||||||
|
var results = document.createElement("div");
|
||||||
|
results.className = "pagefind-ui__results";
|
||||||
|
|
||||||
|
form.appendChild(input);
|
||||||
|
this.element.appendChild(form);
|
||||||
|
this.element.appendChild(results);
|
||||||
|
|
||||||
|
this.input = input;
|
||||||
|
this.results = results;
|
||||||
|
|
||||||
|
var debounce = null;
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
window.clearTimeout(debounce);
|
||||||
|
debounce = window.setTimeout(function () {
|
||||||
|
self.search(input.value);
|
||||||
|
}, 120);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
PagefindUI.prototype.load = function () {
|
||||||
|
if (this.pages) {
|
||||||
|
return Promise.resolve(this.pages);
|
||||||
|
}
|
||||||
|
if (this.loadPromise) {
|
||||||
|
return this.loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
this.loadPromise = fetch(this.indexUrl, { credentials: "same-origin" })
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("pagefind index request failed: " + response.status);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
self.pages = (data && data.pages) || [];
|
||||||
|
return self.pages;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
self.pages = [];
|
||||||
|
return self.pages;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.loadPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
PagefindUI.prototype.search = function (query) {
|
||||||
|
var self = this;
|
||||||
|
var terms = tokenize(query);
|
||||||
|
|
||||||
|
if (terms.length === 0) {
|
||||||
|
this.results.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.load().then(function (pages) {
|
||||||
|
var matches = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < pages.length; i++) {
|
||||||
|
var page = pages[i];
|
||||||
|
var title = (page.title || "").toLowerCase();
|
||||||
|
var body = (page.text || "").toLowerCase();
|
||||||
|
var score = 0;
|
||||||
|
var matchedAll = true;
|
||||||
|
|
||||||
|
for (var t = 0; t < terms.length; t++) {
|
||||||
|
var term = terms[t];
|
||||||
|
var bodyHits = body.split(term).length - 1;
|
||||||
|
var titleHits = title.split(term).length - 1;
|
||||||
|
|
||||||
|
if (bodyHits === 0 && titleHits === 0) {
|
||||||
|
matchedAll = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title matches are weighted more heavily than body matches.
|
||||||
|
score += bodyHits + titleHits * 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedAll) {
|
||||||
|
matches.push({ page: page, score: score });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort(function (a, b) {
|
||||||
|
return b.score - a.score;
|
||||||
|
});
|
||||||
|
|
||||||
|
self.renderResults(matches.slice(0, 10), terms);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
PagefindUI.prototype.renderResults = function (matches, terms) {
|
||||||
|
this.results.innerHTML = "";
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
var message = document.createElement("p");
|
||||||
|
message.className = "pagefind-ui__message";
|
||||||
|
message.textContent = this.zeroResults;
|
||||||
|
this.results.appendChild(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = document.createElement("ol");
|
||||||
|
list.className = "pagefind-ui__result-list";
|
||||||
|
|
||||||
|
for (var i = 0; i < matches.length; i++) {
|
||||||
|
var page = matches[i].page;
|
||||||
|
|
||||||
|
var item = document.createElement("li");
|
||||||
|
item.className = "pagefind-ui__result";
|
||||||
|
|
||||||
|
var link = document.createElement("a");
|
||||||
|
link.className = "pagefind-ui__result-link";
|
||||||
|
link.href = page.url;
|
||||||
|
link.textContent = page.title || page.url;
|
||||||
|
|
||||||
|
var excerpt = document.createElement("p");
|
||||||
|
excerpt.className = "pagefind-ui__result-excerpt";
|
||||||
|
buildExcerpt(excerpt, page.text || "", terms);
|
||||||
|
|
||||||
|
item.appendChild(link);
|
||||||
|
item.appendChild(excerpt);
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.results.appendChild(list);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build an excerpt around the first matching term and highlight all terms
|
||||||
|
// with <mark>, using safe text nodes (never innerHTML on untrusted text).
|
||||||
|
function buildExcerpt(target, text, terms) {
|
||||||
|
var lower = text.toLowerCase();
|
||||||
|
var firstHit = -1;
|
||||||
|
|
||||||
|
for (var t = 0; t < terms.length; t++) {
|
||||||
|
var idx = lower.indexOf(terms[t]);
|
||||||
|
if (idx !== -1 && (firstHit === -1 || idx < firstHit)) {
|
||||||
|
firstHit = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var start = firstHit === -1 ? 0 : Math.max(0, firstHit - 60);
|
||||||
|
var snippet = text.slice(start, start + 220);
|
||||||
|
if (start > 0) {
|
||||||
|
snippet = "…" + snippet;
|
||||||
|
}
|
||||||
|
if (start + 220 < text.length) {
|
||||||
|
snippet = snippet + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightInto(target, snippet, terms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightInto(target, snippet, terms) {
|
||||||
|
var escaped = terms
|
||||||
|
.map(function (term) {
|
||||||
|
return term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
})
|
||||||
|
.filter(function (term) {
|
||||||
|
return term.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (escaped.length === 0) {
|
||||||
|
target.appendChild(document.createTextNode(snippet));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pattern = new RegExp("(" + escaped.join("|") + ")", "giu");
|
||||||
|
var lastIndex = 0;
|
||||||
|
var match;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(snippet)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
target.appendChild(
|
||||||
|
document.createTextNode(snippet.slice(lastIndex, match.index))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
var mark = document.createElement("mark");
|
||||||
|
mark.textContent = match[0];
|
||||||
|
target.appendChild(mark);
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
|
||||||
|
// Guard against zero-length matches looping forever.
|
||||||
|
if (match.index === pattern.lastIndex) {
|
||||||
|
pattern.lastIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < snippet.length) {
|
||||||
|
target.appendChild(document.createTextNode(snippet.slice(lastIndex)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.PagefindUI = PagefindUI;
|
||||||
|
})();
|
||||||
@@ -15,11 +15,12 @@
|
|||||||
}
|
}
|
||||||
initialized = true;
|
initialized = true;
|
||||||
var placeholder = root.getAttribute('data-search-placeholder') || 'Search...';
|
var placeholder = root.getAttribute('data-search-placeholder') || 'Search...';
|
||||||
|
var zeroResults = root.getAttribute('data-search-no-results') || 'No results found';
|
||||||
new PagefindUI({
|
new PagefindUI({
|
||||||
element: root,
|
element: root,
|
||||||
showSubResults: true,
|
showSubResults: true,
|
||||||
showImages: false,
|
showImages: false,
|
||||||
translations: { placeholder: placeholder }
|
translations: { placeholder: placeholder, zero_results: zeroResults }
|
||||||
});
|
});
|
||||||
var input = root.querySelector('input');
|
var input = root.querySelector('input');
|
||||||
if (input) {
|
if (input) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user