Compare commits
49 Commits
ac4f5a3580
...
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 | |||
| 71fb99af16 | |||
| 0808b27057 |
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
- [Fix all test failures](feedback_fix_all_failures.md) — Never dismiss failures as pre-existing; fix everything
|
- [Fix all test failures](feedback_fix_all_failures.md) — Never dismiss failures as pre-existing or flaky; investigate and fix
|
||||||
|
- [Debug targeted](feedback_targeted_debugging.md) — Analyze the code and fix; don't brute-force with repeated suite runs
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: Fix all test failures
|
name: Fix all test failures including flaky ones
|
||||||
description: Never dismiss test failures as pre-existing — if tests fail after changes, fix them
|
description: Never dismiss test failures as pre-existing or flaky — investigate root cause and stabilize
|
||||||
type: feedback
|
type: feedback
|
||||||
---
|
---
|
||||||
|
|
||||||
All test failures after changes must be fixed, even if they appear unrelated. The test suite was clean before, so any failure is the responsibility of the current change.
|
All test failures must be fixed, even if they appear unrelated to current changes. The test suite was clean before, so any failure is my responsibility.
|
||||||
|
|
||||||
**Why:** The user confirmed the suite was green before. Dismissing failures as "pre-existing" is wrong and wastes time.
|
Flaky tests are deeper problems waiting to surface. Running a test in isolation and seeing it pass is never enough — must find out why it was flaky in the full suite run and make it stable.
|
||||||
|
|
||||||
**How to apply:** After making changes, if any test fails, investigate and fix it before reporting the task as done. Never stash/skip/ignore failures.
|
**Why:** Dismissing failures as "pre-existing" or "flaky" is wrong. Flaky tests indicate real issues (race conditions, test pollution, shared state) that will bite harder later.
|
||||||
|
|
||||||
|
**How to apply:** After making changes, if any test fails: investigate the root cause, fix it, and verify it passes reliably in the full suite. Never stash, never skip, never re-run and hope. Never dismiss ordering-dependent failures — find and fix the shared state or race condition.
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: Debug targeted, don't brute-force
|
||||||
|
description: When investigating flaky tests, analyze the code and fix — don't brute-force with repeated full suite runs
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
If you already know which test is failing and why, fix it. Don't waste time running the full suite 20 times hoping to capture output.
|
||||||
|
|
||||||
|
**Why:** It's slow, wasteful, and avoids thinking. Analyze the code, understand the race, fix it.
|
||||||
|
|
||||||
|
**How to apply:** When a test fails, read the test, understand what it does, identify the root cause, and fix it. Only re-run to verify the fix, not to gather more data you already have.
|
||||||
@@ -6,7 +6,17 @@
|
|||||||
"Bash(mix dialyzer *)",
|
"Bash(mix dialyzer *)",
|
||||||
"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 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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user