Compare commits
25 Commits
f2b340ba86
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "phoenix",
|
||||||
|
"runtimeExecutable": "mix",
|
||||||
|
"runtimeArgs": ["phx.server"],
|
||||||
|
"port": 4000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,7 +7,16 @@
|
|||||||
"Bash(mix ecto.migrate)",
|
"Bash(mix ecto.migrate)",
|
||||||
"Bash(git add *)",
|
"Bash(git add *)",
|
||||||
"Bash(git push *)",
|
"Bash(git push *)",
|
||||||
"Bash(git -C /Users/gb/Projects/bDS2 status)"
|
"Bash(git -C /Users/gb/Projects/bDS2 status)",
|
||||||
|
"Bash(git status *)",
|
||||||
|
"Bash(mix assets.deploy)",
|
||||||
|
"Bash(mix phx.server)",
|
||||||
|
"mcp__Claude_Preview__preview_start",
|
||||||
|
"mcp__Claude_in_Chrome__navigate",
|
||||||
|
"mcp__Claude_in_Chrome__computer",
|
||||||
|
"mcp__Claude_in_Chrome__browser_batch",
|
||||||
|
"mcp__Claude_in_Chrome__javascript_tool",
|
||||||
|
"Bash(allium check *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,10 +3,11 @@
|
|||||||
/deps/
|
/deps/
|
||||||
/dist/
|
/dist/
|
||||||
/doc/
|
/doc/
|
||||||
|
/tmp/
|
||||||
/.elixir_ls/
|
/.elixir_ls/
|
||||||
/erl_crash.dump
|
/erl_crash.dump
|
||||||
/node_modules/
|
/node_modules/
|
||||||
/priv/data/*.db
|
/priv/data/*.db
|
||||||
/priv/data/*.db-shm
|
/priv/data/*.db-shm
|
||||||
/priv/data/*.db-wal
|
/priv/data/*.db-wal
|
||||||
*.ez
|
*.eztmp/
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
|
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
|
||||||
- English fallback is allowed only when the requested locale is unsupported by available locale files
|
- English fallback is allowed only when the requested locale is unsupported by available locale files
|
||||||
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
|
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
|
||||||
|
- When adding new `msgid` entries, you MUST provide translations for ALL supported locales (de, fr, it, es) — empty `msgstr` values are not acceptable
|
||||||
|
|
||||||
> **No hardcoded user-facing text. No exceptions.**
|
> **No hardcoded user-facing text. No exceptions.**
|
||||||
|
|
||||||
|
|||||||
555
CODESMELL.md
555
CODESMELL.md
@@ -1,555 +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~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-11
|
|
||||||
- **What was done:**
|
|
||||||
- Replaced `if result == :ok and ...` in `rewrite_template_file/2` with a `case` expression using pattern matching and a `when` guard.
|
|
||||||
- `Persistence.atomic_write` result is now matched directly: `:ok when file_path changed` triggers old file cleanup, `other` (including `{:error, _}`) is returned as-is.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-028 — Broad `rescue` Swallowing Template Errors~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-11
|
|
||||||
- **What was done:**
|
|
||||||
- Replaced `Liquex.FileSystem.read_template_file` (which raises on missing templates) with `BDS.Rendering.FileSystem.try_read` (returns `{:ok, source}` / `{:error, :enoent}`).
|
|
||||||
- Missing template now logs a warning and returns `""` without raising.
|
|
||||||
- Extracted `render_macro_source/4` to separate file reading from template parsing/rendering.
|
|
||||||
- `Liquex.render!` rescue remains specific to `Liquex.Error` (no non-bang variant exists in Liquex).
|
|
||||||
- No broad `rescue _error ->` or `rescue _ ->` clauses remain in `filters.ex`.
|
|
||||||
- Added 3 source-level tests in `test/bds/csm028_broad_rescue_test.exs`: no broad rescue clauses, no `read_template_file` usage, `try_read` is used instead.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-029 — `length/1` in Guards or Comparisons~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-11
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/generation/outputs.ex`** — `category_route_paths`, `tag_route_paths`, `date_route_paths`:
|
|
||||||
- Bound `post_count = length(posts)` at the start of each `Enum.flat_map` callback, before passing to `paginated_archive_paths`. Eliminates inline `length/1` calls inside loop bodies.
|
|
||||||
- **`lib/bds/ui/sidebar.ex`** — `build_post_section`:
|
|
||||||
- Bound `post_count = length(posts)` before the map literal instead of computing `length(posts)` inline in the `count:` field.
|
|
||||||
- Added 3 source-level tests in `test/bds/csm029_length_in_guards_test.exs`: no inline `length(posts)` in `paginated_archive_paths` calls, no inline `length()` in `build_post_section` map literal, no inline `length(posts)` in route path function callbacks.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-030 — Unchecked `File.mkdir_p` / `File.mkdir_p!`~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-11
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/media/thumbnails.ex`** — Already fixed in CSM-009; `File.mkdir_p` is inside a `with` chain in `write_all_thumbnails`.
|
|
||||||
- **`lib/bds/media/sidecars.ex`** — Removed redundant `File.mkdir_p` calls from `write_sidecar/2` and `write_translation_sidecar/3` (the underlying `Persistence.atomic_write` already handles `mkdir_p`). Updated specs to return `:ok | {:error, File.posix()}`. Updated callers (`sync_media_sidecar`, `sync_media_translation_sidecar`) to propagate errors.
|
|
||||||
- **`lib/bds/media.ex`** — Replaced all `:ok = write_sidecar(...)` and `:ok = write_translation_sidecar(...)` match assertions with `log_sidecar_error/2` (mirrors existing `log_thumbnail_error/2` pattern). Sidecar write failures are logged as warnings but don't fail the DB operation.
|
|
||||||
- **`lib/bds/media/linking.ex`** — Same `log_sidecar_error/2` pattern for post link/unlink sidecar writes.
|
|
||||||
- **`lib/bds/release_packaging.ex`** — Replaced `File.mkdir_p!` with `File.mkdir_p` in `reset_output/1` (return value propagated through `with` chain in `package/1`). Replaced `File.mkdir_p!` with `with :ok <- File.mkdir_p(...)` in `copy_release/2`. Replaced `File.write!` with `File.write` in `write_manifest/1`.
|
|
||||||
- Added 6 tests in `test/bds/csm030_unchecked_mkdir_test.exs`: source-level assertions for no unchecked `File.mkdir_p`, no bang variants, no `:ok =` match assertions on sidecar writes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-031 — `try/rescue` Instead of `with` and Error Tuples~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-27
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/rendering/filters.ex`** — Extracted `safe_liquex_render/3` private helper that isolates the unavoidable `Liquex.render!` rescue into a single function returning `{:ok, binary} | {:error, String.t()}`. Replaced inline `try/rescue` in `render_macro_source/4` with a `with` chain using the helper.
|
|
||||||
- **`lib/bds/rendering/template_selection.ex`** — Same pattern: extracted `safe_liquex_render/3` helper, replaced inline `try/rescue` in `render_template/3` with a `with` chain.
|
|
||||||
- **`lib/bds/rendering/template_selection.ex`** — `load_bundled_template_source/3`: Replaced raising `Liquex.FileSystem.read_template_file` with `FileSystem.try_read` (returns `{:ok, source} | {:error, :enoent}`), eliminating the function-level `rescue` block entirely. Uses a `with` chain for control flow.
|
|
||||||
- **`lib/bds/desktop/shell_data.ex`** — Already fixed by CSM-010; no `try/rescue` blocks remain.
|
|
||||||
- Added 7 tests in `test/bds/csm031_try_rescue_test.exs`: source-level assertions that no inline `try/rescue` around `Liquex.render!` exists in either file, both files define `safe_liquex_render` helpers, `load_bundled_template_source` has no rescue block and uses `FileSystem.try_read`, and `shell_data.ex` has no try/rescue.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-032 — `Map.get` with Default Instead of Pattern Matching~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-27
|
|
||||||
- **What was done:**
|
|
||||||
- **Metadata struct access** — Replaced `Map.get(metadata, :key)` / `Map.get(metadata, :key, default)` with dot access (`metadata.key`) across 10 files that consume the well-defined metadata map from `Metadata.load_state()`:
|
|
||||||
- `lib/bds/posts/translation_validation.ex` — `:main_language`, `:blog_languages`
|
|
||||||
- `lib/bds/posts/auto_translation.ex` — `:main_language`, `:blog_languages`
|
|
||||||
- `lib/bds/desktop/shell_commands.ex` — `:main_language`, `:blog_languages`
|
|
||||||
- `lib/bds/desktop/shell_live/settings_editor/project_settings.ex` — all 10 metadata fields
|
|
||||||
- `lib/bds/desktop/shell_live/settings_editor/style_editor.ex` — `:pico_theme`
|
|
||||||
- `lib/bds/desktop/shell_live/settings_editor/managed_categories.ex` — `:categories`, `:category_settings`
|
|
||||||
- `lib/bds/desktop/shell_live/settings_editor/publishing_settings.ex` — `:publishing_preferences`
|
|
||||||
- `lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex` — `:categories`
|
|
||||||
- `lib/bds/desktop/shell_live/import_editor/analysis_state.ex` — `:default_author`
|
|
||||||
- `lib/bds/import_execution.ex` — `:default_author`
|
|
||||||
- **Overlay context maps** — Replaced `Map.get(delete_details, :key, default)` and `Map.get(merge, :key, default)` with pattern matching in `lib/bds/desktop/overlay.ex`:
|
|
||||||
- `open(:media, :confirm_delete, ...)` — pattern matches `%{title:, entity_name:, entity_type:, reference_list:}` from `context.delete_details`
|
|
||||||
- `open(:tags, :confirm_merge, ...)` — pattern matches `%{title:, message:}` from `context.merge_details`
|
|
||||||
- `normalize_ai_fields/1` — pattern matches `%{key:, label:, current_value:, suggested_value:, locked:}` from each field
|
|
||||||
- `language_picker/2` — extracts `existing_translations`, `language_names`, `language_flags` into local bindings before the loop, eliminating nested `Map.get(Map.get(...))` calls
|
|
||||||
- **Overlay media/post maps** — Replaced `Map.get(media, :field)` with dot access in `to_insert_media_result`, `to_gallery_image`, `gallery_images`, search filtering, and `to_insert_link_result` (media/post maps are built with known keys by `overlay_components.ex`)
|
|
||||||
- **Generation pipeline** — Replaced `Map.get(post, :field)` with `post.field` for Post struct fields across:
|
|
||||||
- `lib/bds/generation/data.ex` — `:author`, `:tags`, `:categories`, `:template_slug`, `:do_not_translate`, `:language` in `build_published_translation_variant` and `resolve_posts_for_language`
|
|
||||||
- `lib/bds/generation/outputs.ex` — `:language` (all occurrences)
|
|
||||||
- `lib/bds/generation/validation.ex` — `:file_path`
|
|
||||||
- `lib/bds/generation/sitemap.ex` — `:do_not_translate`
|
|
||||||
- `lib/bds/preview.ex` — `:template_slug`
|
|
||||||
- Kept `Map.get` where appropriate: dynamic field access (`Map.get(post, field)` with variable keys), truly optional keys (`:translation_source_slug` on mixed struct/map lists), external data lookups (string-keyed JSON, form params), and hash-map lookups with defaults.
|
|
||||||
- Updated `test/bds/desktop/overlay_test.exs` to provide complete `delete_details` and `merge_details` maps matching the real contract from `overlay_components.ex`.
|
|
||||||
- Added 18 tests in `test/bds/csm032_map_get_pattern_match_test.exs`: source-level assertions that metadata consumers use dot access, overlay uses pattern matching for known structures, and generation pipeline uses dot access for Post struct fields.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
61
SPECGAPS.md
61
SPECGAPS.md
@@ -10,40 +10,43 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
|
|||||||
|
|
||||||
| ID | Gap | Spec | Code | Path |
|
| ID | Gap | Spec | Code | Path |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| A1-1 | No `archived→draft` or `archived→published` transition | post.allium:121-122 | No code path to unarchive | Fix code: implement unarchive transitions |
|
| 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` skips translation cleanup | Fix code: delete PostTranslation rows + files |
|
| 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` does not delete old file | Fix code: add old file deletion on path change |
|
| 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 | `lib/bds/frontmatter.ex:38-39` writes false | Fix code: omit `doNotTranslate` when false |
|
| 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 | No auto-save timer | Fix code: implement auto-save on idle + unmount + tab switch |
|
| 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 | Server serves static pre-generated files | Fix code: implement on-demand template rendering for post/archive/language routes |
|
| 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 | Only levels 1 and 4 implemented; tag/category fallback unused | Fix code: implement levels 2-3 in template_selection.ex |
|
| 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 | No validation gate before publish | Fix code: add validation step before publish |
|
| A1-8 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Fix code: add validation step before publish |
|
||||||
| A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Fix code: implement preset color palette popover |
|
| A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Fix code: implement preset color palette popover |
|
||||||
| A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create |
|
| A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create |
|
||||||
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown |
|
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown |
|
||||||
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
|
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
|
||||||
|
| A1-13 | Git sidebar shows only "Working tree" placeholder | sidebar_views.allium:651-770 | `sidebar.ex:782-798` returns single entity_list item; `BDS.Git` has full status/diff/commit/history/fetch/pull/push/prune_lfs but sidebar doesn't use it | Fix code: wire sidebar `git_view/0` to `BDS.Git` — render branch, ahead/behind, status file list, commit input, history entries, action buttons per spec |
|
||||||
|
| A1-14 | Embedding uses TF-IDF hash projection instead of real neural model | embedding.allium:44-53, invariants ModelCaching/VectorCacheInDb | `backends/in_app.ex` hashes terms into sparse vectors via `:erlang.phash2`; no ONNX model, no `"query: "` prefix, no mean pooling, vectors stored as JSON text not Float32Array BLOB, snapshot-based neighbor lookup instead of USearch HNSW index | Fix code: (1) add Bumblebee + ONNX runtime deps to run `Xenova/multilingual-e5-small`, (2) implement lazy model download + cache in app data dir, (3) `"query: "` prefix + mean pooling + L2 norm in backend, (4) store vectors as binary BLOB (1536 bytes), (5) replace JSON snapshot with USearch HNSW index (cosine, M=16, ef=128/64, 5s debounce), (6) cross-language semantic similarity must work |
|
||||||
|
| 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)
|
### A2. Spec Should Update (code is normative)
|
||||||
|
|
||||||
| ID | Gap | Spec | Code | Path |
|
| 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 | Drop from spec or mark future |
|
| 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 | Update spec to per-project scoping |
|
| 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 `[...]` | Update spec |
|
| 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 | Update spec to gray-matter style |
|
| 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 | Update spec to match written fields |
|
| 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 | Update spec to per-field model |
|
| 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 | Update spec |
|
| 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 | Update spec |
|
| 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 | Update spec to file-only model |
|
| 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]` | Update spec |
|
| 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 | Mark as partial/TODO in spec |
|
| 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 | Update spec or fix code |
|
| 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 | Update spec or fix code |
|
| 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` | Update spec to :file/:tool |
|
| 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 | Update spec to camelCase |
|
| 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 | Update spec: don't enumerate; just say "Snowball stemmers via library" |
|
| 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 | Drop from spec |
|
| 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,8 +63,8 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
|
|||||||
| B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity |
|
| 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-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-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` | Add to frontmatter.allium MediaSidecar |
|
| 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` | Add to frontmatter.allium |
|
| 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-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-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-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium |
|
||||||
@@ -97,8 +100,8 @@ All reconciled to follow code. Specs must be self-consistent and match code.
|
|||||||
| ID | Conflict | Resolution | Path |
|
| 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-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 | Update frontmatter.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 | Update translation.allium + frontmatter.allium |
|
| 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -181,7 +184,7 @@ All reconciled to follow code. Specs must be self-consistent and match code.
|
|||||||
|
|
||||||
## Priority Order for Resolution
|
## Priority Order for Resolution
|
||||||
|
|
||||||
1. **A1-1 through A1-12** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown)
|
1. **A1-1 through A1-14** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown, real embedding model)
|
||||||
2. **D1-1 through D1-18** — untested invariants/guarantees
|
2. **D1-1 through D1-18** — untested invariants/guarantees
|
||||||
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
|
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
|
||||||
4. **B1-1 through B1-6** — major code behaviors missing from spec
|
4. **B1-1 through B1-6** — major code behaviors missing from spec
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -175,6 +176,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)}
|
||||||
@@ -251,6 +253,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 +273,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 +405,41 @@ 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 +621,68 @@ 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
|
||||||
@@ -1045,6 +1148,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
|
||||||
@@ -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,6 +22,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
|||||||
"main_language" => metadata.main_language || "en",
|
"main_language" => metadata.main_language || "en",
|
||||||
"default_author" => metadata.default_author || "",
|
"default_author" => metadata.default_author || "",
|
||||||
"max_posts_per_page" => Integer.to_string(metadata.max_posts_per_page),
|
"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" =>
|
||||||
metadata.blogmark_category ||
|
metadata.blogmark_category ||
|
||||||
List.first(metadata.categories) || "article",
|
List.first(metadata.categories) || "article",
|
||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -15,6 +15,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,7 +74,24 @@ 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))
|
||||||
|
base_label = max_label_value()
|
||||||
|
|
||||||
|
{rows, _next_label} =
|
||||||
|
Enum.reduce(posts, {[], base_label + 1}, fn post, {acc, next_label} ->
|
||||||
|
existing_key = Map.get(existing_keys, post.id)
|
||||||
|
|
||||||
|
case compute_key_data(post, existing_key, next_label) do
|
||||||
|
:skip ->
|
||||||
|
{acc, next_label}
|
||||||
|
|
||||||
|
{:upsert, row} ->
|
||||||
|
bump = if existing_key, do: 0, else: 1
|
||||||
|
{[row | acc], next_label + bump}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
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)}
|
||||||
else
|
else
|
||||||
@@ -104,13 +122,28 @@ defmodule BDS.Embeddings do
|
|||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
existing_keys = preload_keys_by_post_id(project_id)
|
||||||
|
base_label = max_label_value()
|
||||||
|
|
||||||
|
{rows, _next_label} =
|
||||||
posts
|
posts
|
||||||
|> Enum.with_index(1)
|
|> Enum.with_index(1)
|
||||||
|> Enum.each(fn {post, index} ->
|
|> Enum.reduce({[], base_label + 1}, fn {post, index}, {acc, next_label} ->
|
||||||
sync_post_if_enabled(post, refresh_index: false)
|
|
||||||
:ok = report_rebuild_progress(on_progress, index, total_posts, "embedding entries")
|
:ok = report_rebuild_progress(on_progress, index, total_posts, "embedding entries")
|
||||||
|
existing_key = Map.get(existing_keys, post.id)
|
||||||
|
|
||||||
|
case compute_key_data(post, existing_key, next_label) do
|
||||||
|
:skip ->
|
||||||
|
{acc, next_label}
|
||||||
|
|
||||||
|
{:upsert, row} ->
|
||||||
|
bump = if existing_key, do: 0, else: 1
|
||||||
|
{[row | acc], next_label + bump}
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
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}
|
||||||
@@ -196,6 +229,53 @@ defmodule BDS.Embeddings do
|
|||||||
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
|
||||||
|
|
||||||
|
defp compute_key_data(%Post{} = post, existing_key, next_label) do
|
||||||
|
body = resolve_post_body(post)
|
||||||
|
raw_text = compose_embedding_source(post.title, body)
|
||||||
|
content_hash = hash_text(raw_text)
|
||||||
|
|
||||||
|
if existing_key && existing_key.content_hash == content_hash do
|
||||||
|
:skip
|
||||||
|
else
|
||||||
|
{:ok, vector} = embed_text(raw_text, post.language)
|
||||||
|
label = if existing_key, do: existing_key.label, else: next_label
|
||||||
|
{:upsert, [label, post.id, post.project_id, content_hash, Jason.encode!(vector)]}
|
||||||
|
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 =
|
||||||
case Repo.get_by(Key, post_id: post_id) do
|
case Repo.get_by(Key, post_id: post_id) do
|
||||||
@@ -227,23 +307,24 @@ 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)
|
base_label = max_label_value()
|
||||||
content_hash = hash_text(compose_embedding_source(post.title, body))
|
|
||||||
|
|
||||||
case Repo.get_by(Key, post_id: post.id, project_id: project_id) do
|
{rows, _next_label} =
|
||||||
%Key{content_hash: ^content_hash} ->
|
Enum.reduce(posts, {[], base_label + 1}, fn post, {acc, next_label} ->
|
||||||
:ok
|
existing_key = Map.get(existing_keys, post.id)
|
||||||
|
|
||||||
_other ->
|
case compute_key_data(post, existing_key, next_label) do
|
||||||
:ok =
|
:skip ->
|
||||||
sync_post_if_enabled(
|
{acc, next_label}
|
||||||
%{post | content: if(post.content in [nil, ""], do: body, else: post.content)},
|
|
||||||
refresh_index: false
|
{:upsert, row} ->
|
||||||
)
|
bump = if existing_key, do: 0, else: 1
|
||||||
|
{[row | acc], next_label + bump}
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
batch_upsert_keys(rows)
|
||||||
:ok = rebuild_snapshot(project_id)
|
:ok = rebuild_snapshot(project_id)
|
||||||
|
|
||||||
indexed =
|
indexed =
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -391,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,
|
||||||
@@ -423,10 +427,12 @@ 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,
|
||||||
@@ -521,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,
|
||||||
@@ -551,10 +559,12 @@ 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,
|
||||||
@@ -571,4 +581,18 @@ defmodule BDS.Generation.Outputs do
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -13,6 +13,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 +73,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 +242,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 +280,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 +301,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 +317,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 +353,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 +441,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 +519,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 +592,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
@@ -154,24 +155,39 @@ 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
|
||||||
|
|
||||||
@@ -204,6 +220,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 +232,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 +244,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
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,14 +143,6 @@ 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
|
|
||||||
"" ->
|
|
||||||
""
|
|
||||||
|
|
||||||
nil ->
|
|
||||||
""
|
|
||||||
|
|
||||||
_id ->
|
|
||||||
case BDS.Rendering.FileSystem.try_read(context.file_system, template_path) do
|
case BDS.Rendering.FileSystem.try_read(context.file_system, template_path) do
|
||||||
{:ok, template_source} ->
|
{:ok, template_source} ->
|
||||||
render_macro_source(template_path, template_source, assigns, context)
|
render_macro_source(template_path, template_source, assigns, context)
|
||||||
@@ -145,7 +153,6 @@ defmodule BDS.Rendering.Filters do
|
|||||||
""
|
""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
defp render_macro_source(template_path, template_source, assigns, context) do
|
defp render_macro_source(template_path, template_source, assigns, context) do
|
||||||
with {:ok, template_ast} <- Liquex.parse(template_source),
|
with {:ok, template_ast} <- Liquex.parse(template_source),
|
||||||
@@ -285,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
@@ -184,10 +186,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 +251,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 +287,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
|
||||||
|
|
||||||
@@ -493,9 +501,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 = %{
|
||||||
@@ -517,7 +526,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
|
||||||
|
|||||||
@@ -1,156 +1,156 @@
|
|||||||
#: 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:54
|
||||||
#, 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:70
|
||||||
#, 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:86
|
||||||
#, 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:46
|
||||||
#, 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:42
|
||||||
#, 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:66
|
||||||
#, 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:62
|
||||||
#, 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:50
|
||||||
#, 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:58
|
||||||
#, 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:82
|
||||||
#, 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:78
|
||||||
#, 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:74
|
||||||
#, 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:31
|
||||||
#, 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:30
|
||||||
#, 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:33
|
||||||
#, 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:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr "YouTube-Video"
|
msgstr "YouTube-Video"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,156 +1,156 @@
|
|||||||
#: 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:54
|
||||||
#, 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:70
|
||||||
#, 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:86
|
||||||
#, 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:46
|
||||||
#, 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:42
|
||||||
#, 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:66
|
||||||
#, 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:62
|
||||||
#, 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:50
|
||||||
#, 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:58
|
||||||
#, 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:82
|
||||||
#, 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:78
|
||||||
#, 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:74
|
||||||
#, 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:31
|
||||||
#, 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:30
|
||||||
#, 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:33
|
||||||
#, 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:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,156 +1,156 @@
|
|||||||
#: 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:54
|
||||||
#, 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:70
|
||||||
#, 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:86
|
||||||
#, 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:46
|
||||||
#, 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:42
|
||||||
#, 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:66
|
||||||
#, 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:62
|
||||||
#, 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:50
|
||||||
#, 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:58
|
||||||
#, 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:82
|
||||||
#, 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:78
|
||||||
#, 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:74
|
||||||
#, 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:31
|
||||||
#, 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:30
|
||||||
#, 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:33
|
||||||
#, 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:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr "Vídeo de YouTube"
|
msgstr "Vídeo de YouTube"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,156 +1,156 @@
|
|||||||
#: 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:54
|
||||||
#, 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:70
|
||||||
#, 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:86
|
||||||
#, 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:46
|
||||||
#, 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:42
|
||||||
#, 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:66
|
||||||
#, 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:62
|
||||||
#, 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:50
|
||||||
#, 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:58
|
||||||
#, 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:82
|
||||||
#, 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:78
|
||||||
#, 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:74
|
||||||
#, 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:31
|
||||||
#, 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:30
|
||||||
#, 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:33
|
||||||
#, 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:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr "Vidéo YouTube"
|
msgstr "Vidéo YouTube"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,156 +1,156 @@
|
|||||||
#: 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:54
|
||||||
#, 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:70
|
||||||
#, 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:86
|
||||||
#, 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:46
|
||||||
#, 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:42
|
||||||
#, 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:66
|
||||||
#, 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:62
|
||||||
#, 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:50
|
||||||
#, 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:58
|
||||||
#, 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:82
|
||||||
#, 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:78
|
||||||
#, 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:74
|
||||||
#, 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:31
|
||||||
#, 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:30
|
||||||
#, 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:33
|
||||||
#, 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:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr "Video YouTube"
|
msgstr "Video YouTube"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,159 +11,159 @@
|
|||||||
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:54
|
||||||
#, 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:70
|
||||||
#, 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:86
|
||||||
#, 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:46
|
||||||
#, 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:42
|
||||||
#, 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:66
|
||||||
#, 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:62
|
||||||
#, 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:50
|
||||||
#, 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:58
|
||||||
#, 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:82
|
||||||
#, 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:78
|
||||||
#, 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:74
|
||||||
#, 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:31
|
||||||
#, 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:30
|
||||||
#, 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:33
|
||||||
#, 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:32
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "YouTube video"
|
msgid "YouTube video"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
1510
priv/gettext/ui.pot
1510
priv/gettext/ui.pot
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule BDS.Repo.Migrations.AddChatConversationSurfaceState do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:chat_conversations) do
|
||||||
|
add :surface_state, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3676,9 +3676,10 @@ button svg, button svg * {
|
|||||||
.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 {
|
||||||
max-width: min(760px, 100%);
|
max-width: min(760px, 100%);
|
||||||
@@ -3689,10 +3690,11 @@ button svg, button svg * {
|
|||||||
color: var(--vscode-editor-foreground);
|
color: var(--vscode-editor-foreground);
|
||||||
}
|
}
|
||||||
.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;
|
||||||
}
|
}
|
||||||
.chat-tool-surface-table {
|
.chat-tool-surface-table {
|
||||||
@@ -3711,18 +3713,276 @@ button svg, button svg * {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--vscode-textCodeBlock-background);
|
background: var(--vscode-textCodeBlock-background);
|
||||||
}
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
.chat-surface-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.chat-surface-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.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);
|
||||||
}
|
}
|
||||||
.chat-panel .chat-input-wrapper:focus-within {
|
.chat-panel .chat-input-wrapper:focus-within {
|
||||||
@@ -3739,10 +3999,14 @@ button svg, button svg * {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -3812,6 +4076,8 @@ button svg, button svg * {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.ai-suggestions-modal, .insert-modal, .language-picker-modal, .confirm-delete-modal, .confirm-dialog, .gallery-overlay-content {
|
.ai-suggestions-modal, .insert-modal, .language-picker-modal, .confirm-delete-modal, .confirm-dialog, .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;
|
||||||
@@ -5071,6 +5337,472 @@ button.import-taxonomy-pill {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
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 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.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-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 {
|
||||||
|
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: var(--vscode-focusBorder, #007fd4);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.metadata-diff-empty p {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
.diff-item-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.diff-item-card {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
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: var(--vscode-sideBar-background);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
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 var(--vscode-panel-border);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
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: var(--vscode-focusBorder, #007fd4);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 22%, transparent);
|
||||||
|
}
|
||||||
|
color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
.file-value .diff-source-label {
|
||||||
|
background: var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 22%, transparent);
|
||||||
|
}
|
||||||
|
color: var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
}
|
||||||
|
.orphan-files-section {
|
||||||
|
border: 1px solid var(--vscode-editorWarning-foreground, #cca700);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 35%, transparent);
|
||||||
|
}
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--vscode-editorWarning-foreground, #cca700);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
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-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: var(--vscode-editor-background);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
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-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;
|
||||||
|
}
|
||||||
@layer components {
|
@layer components {
|
||||||
.ui-button {
|
.ui-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -9041,6 +9041,11 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}"
|
|||||||
runMenuRuntimeCommand(String(action));
|
runMenuRuntimeCommand(String(action));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.handleEvent("url-state", ({ path }) => {
|
||||||
|
if (path && window.location.pathname + window.location.search !== path) {
|
||||||
|
window.history.replaceState({}, "", path);
|
||||||
|
}
|
||||||
|
});
|
||||||
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
||||||
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
|
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
|
||||||
this.el.addEventListener("load", this.handleThumbnailLoad, true);
|
this.el.addEventListener("load", this.handleThumbnailLoad, true);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ value PostEditorView {
|
|||||||
metadata: PostEditorMetadata
|
metadata: PostEditorMetadata
|
||||||
metadata_expanded: Boolean -- starts expanded when title is empty
|
metadata_expanded: Boolean -- starts expanded when title is empty
|
||||||
excerpt_expanded: Boolean
|
excerpt_expanded: Boolean
|
||||||
editor_mode: String -- visual | markdown | preview
|
editor_mode: String -- markdown | preview
|
||||||
footer: PostEditorFooter
|
footer: PostEditorFooter
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,15 +152,15 @@ surface PostEditorSurface {
|
|||||||
-- Collapsible section with textarea (4 rows).
|
-- Collapsible section with textarea (4 rows).
|
||||||
|
|
||||||
@guarantee EditorBodyToolbar
|
@guarantee EditorBodyToolbar
|
||||||
-- Toolbar: "Content" label, mode toggle (Visual/Markdown/Preview),
|
-- Toolbar: "Content" label, mode toggle (Markdown/Preview),
|
||||||
-- action buttons (markdown mode only): Gallery (with media count),
|
-- action buttons (markdown mode only): Gallery (with media count),
|
||||||
-- Insert Post Link, Insert Media.
|
-- Insert Post Link, Insert Media.
|
||||||
|
|
||||||
@guarantee EditorModes
|
@guarantee EditorModes
|
||||||
-- Visual: rich-text WYSIWYG editor.
|
|
||||||
-- Markdown: code editor with markdown-with-macros language,
|
-- Markdown: code editor with markdown-with-macros language,
|
||||||
-- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font.
|
-- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font.
|
||||||
-- Preview: iframe showing rendered preview.
|
-- Preview: iframe showing rendered preview.
|
||||||
|
-- Note: visual/WYSIWYG mode not implemented; "visual" normalizes to markdown.
|
||||||
|
|
||||||
@guarantee DragDropImages
|
@guarantee DragDropImages
|
||||||
-- Drop image file onto editor area triggers import chain.
|
-- Drop image file onto editor area triggers import chain.
|
||||||
@@ -193,8 +193,8 @@ invariant PostDirtyTracking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
invariant PostEditorModePersistence {
|
invariant PostEditorModePersistence {
|
||||||
-- Editor mode (visual/markdown/preview) persists per session.
|
-- Editor mode (markdown/preview) persists per session.
|
||||||
-- Default mode comes from editor settings.
|
-- Default mode comes from editor settings (markdown).
|
||||||
}
|
}
|
||||||
|
|
||||||
-- ─── Post editor actions ────────────────────────────────────
|
-- ─── Post editor actions ────────────────────────────────────
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ value SettingsProjectSection {
|
|||||||
blog_languages: List<String> -- checkboxes (main language disabled)
|
blog_languages: List<String> -- checkboxes (main language disabled)
|
||||||
default_author: String -- text input
|
default_author: String -- text input
|
||||||
max_posts_per_page: Integer -- number input (1-500, default 50)
|
max_posts_per_page: Integer -- number input (1-500, default 50)
|
||||||
|
image_import_concurrency: Integer -- number input (1-8, default 4)
|
||||||
blogmark_category: String -- select from categories
|
blogmark_category: String -- select from categories
|
||||||
}
|
}
|
||||||
|
|
||||||
value SettingsEditorSection {
|
value SettingsEditorSection {
|
||||||
default_mode: String -- select: wysiwyg | markdown | preview
|
default_mode: String -- select: markdown | preview
|
||||||
diff_view_style: String -- select: inline | side-by-side
|
diff_view_style: String -- select: inline | side-by-side
|
||||||
wrap_long_lines: Boolean -- checkbox
|
wrap_long_lines: Boolean -- checkbox
|
||||||
hide_unchanged_regions: Boolean -- checkbox
|
hide_unchanged_regions: Boolean -- checkbox
|
||||||
@@ -92,6 +93,9 @@ invariant SettingsMCPAgents {
|
|||||||
config {
|
config {
|
||||||
settings_max_posts_per_page: Integer = 500
|
settings_max_posts_per_page: Integer = 500
|
||||||
settings_default_posts_per_page: Integer = 50
|
settings_default_posts_per_page: Integer = 50
|
||||||
|
settings_image_import_concurrency_default: Integer = 4
|
||||||
|
settings_image_import_concurrency_min: Integer = 1
|
||||||
|
settings_image_import_concurrency_max: Integer = 8
|
||||||
settings_system_prompt_rows: Integer = 12
|
settings_system_prompt_rows: Integer = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +135,7 @@ surface SettingsViewSurface {
|
|||||||
-- Section 1: Project Name, Description (textarea 3 rows), Data Path (text + Browse + Reset),
|
-- Section 1: Project Name, Description (textarea 3 rows), Data Path (text + Browse + Reset),
|
||||||
-- Public URL, Main Language (select), Blog Languages (checkbox grid, main disabled),
|
-- Public URL, Main Language (select), Blog Languages (checkbox grid, main disabled),
|
||||||
-- Default Author, Max Posts Per Page (number 1-500),
|
-- Default Author, Max Posts Per Page (number 1-500),
|
||||||
|
-- Image Import Concurrency (number 1-8, default 4),
|
||||||
-- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save button.
|
-- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save button.
|
||||||
|
|
||||||
@guarantee BookmarkletCopy
|
@guarantee BookmarkletCopy
|
||||||
@@ -138,7 +143,7 @@ surface SettingsViewSurface {
|
|||||||
-- Bookmarklet uses project's publicUrl to construct POST endpoint.
|
-- Bookmarklet uses project's publicUrl to construct POST endpoint.
|
||||||
|
|
||||||
@guarantee EditorSection
|
@guarantee EditorSection
|
||||||
-- Section 2: Default Editor Mode (select: WYSIWYG/Markdown/Preview),
|
-- Section 2: Default Editor Mode (select: Markdown/Preview),
|
||||||
-- Diff View Style (select: Inline/Side-by-side),
|
-- Diff View Style (select: Inline/Side-by-side),
|
||||||
-- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox).
|
-- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox).
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,14 @@ invariant VectorCacheInDb {
|
|||||||
-- Enables instant reload without re-embedding
|
-- Enables instant reload without re-embedding
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invariant RealNeuralModel {
|
||||||
|
-- Embeddings MUST be produced by the actual ONNX neural model (multilingual-e5-small),
|
||||||
|
-- not by lexical approximations (TF-IDF, bag-of-words, hash projections).
|
||||||
|
-- Cross-language semantic similarity is a primary requirement:
|
||||||
|
-- posts in different languages about the same topic must produce similar vectors.
|
||||||
|
-- This is only achievable with the trained multilingual transformer model.
|
||||||
|
}
|
||||||
|
|
||||||
invariant ModelCaching {
|
invariant ModelCaching {
|
||||||
-- Model files (~100 MB) downloaded from Hugging Face Hub on first use
|
-- Model files (~100 MB) downloaded from Hugging Face Hub on first use
|
||||||
-- Cached in app data directory, persists across sessions
|
-- Cached in app data directory, persists across sessions
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ rule ImportMediaSideEffects {
|
|||||||
if media.is_image:
|
if media.is_image:
|
||||||
ensures: ThumbnailsGenerated(media)
|
ensures: ThumbnailsGenerated(media)
|
||||||
-- small=150px, medium=400px, large=800px, ai=448x448
|
-- small=150px, medium=400px, large=800px, ai=448x448
|
||||||
-- Asynchronous, emits thumbnailsGenerated on completion
|
-- Synchronous (awaited), logged on error
|
||||||
ensures: FTSIndexUpdated(media)
|
ensures: FTSIndexUpdated(media)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ rule DeleteMediaTranslationSideEffects {
|
|||||||
-- updatePost | rename only* | yes | if Δ | no | no | yes | no
|
-- updatePost | rename only* | yes | if Δ | no | no | yes | no
|
||||||
-- publishPost | .md + trans | yes | yes | no | no | yes | no
|
-- publishPost | .md + trans | yes | yes | no | no | yes | no
|
||||||
-- deletePost | delete .md | del | del | no | Δ media | del | no
|
-- deletePost | delete .md | del | del | no | Δ media | del | no
|
||||||
-- importMedia | copy file | yes | no | async | write | no | no
|
-- importMedia | copy file | yes | no | sync | write | no | no
|
||||||
-- updateMedia | no | yes | no | no | rewrite | no | no
|
-- updateMedia | no | yes | no | no | rewrite | no | no
|
||||||
-- replaceMediaFile | overwrite | no | no | regen | no | no | no
|
-- replaceMediaFile | overwrite | no | no | regen | no | no | no
|
||||||
-- deleteMedia | delete all | del | no | del | del all | no | no
|
-- deleteMedia | delete all | del | no | del | del all | no | no
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ surface PostFrontmatterSurface {
|
|||||||
frontmatter.title
|
frontmatter.title
|
||||||
frontmatter.slug
|
frontmatter.slug
|
||||||
frontmatter.status
|
frontmatter.status
|
||||||
frontmatter.published_at
|
frontmatter.publishedAt
|
||||||
frontmatter.tags
|
frontmatter.tags
|
||||||
frontmatter.categories
|
frontmatter.categories
|
||||||
}
|
}
|
||||||
@@ -35,11 +35,11 @@ surface MediaSidecarSurface {
|
|||||||
|
|
||||||
exposes:
|
exposes:
|
||||||
sidecar.id
|
sidecar.id
|
||||||
sidecar.original_name
|
sidecar.originalName
|
||||||
sidecar.mime_type
|
sidecar.mimeType
|
||||||
sidecar.width
|
sidecar.width
|
||||||
sidecar.height
|
sidecar.height
|
||||||
sidecar.updated_at
|
sidecar.updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
surface TemplateFrontmatterSurface {
|
surface TemplateFrontmatterSurface {
|
||||||
@@ -70,8 +70,8 @@ surface MenuOpmlSurface {
|
|||||||
|
|
||||||
exposes:
|
exposes:
|
||||||
document.header.title
|
document.header.title
|
||||||
document.header.date_created
|
document.header.dateCreated
|
||||||
document.header.date_modified
|
document.header.dateModified
|
||||||
for item in document.body:
|
for item in document.body:
|
||||||
item.kind
|
item.kind
|
||||||
item.label
|
item.label
|
||||||
@@ -88,6 +88,7 @@ config {
|
|||||||
|
|
||||||
value PostFrontmatter {
|
value PostFrontmatter {
|
||||||
-- File path: posts/{YYYY}/{MM}/{slug}.md
|
-- File path: posts/{YYYY}/{MM}/{slug}.md
|
||||||
|
-- All keys serialized as camelCase in YAML frontmatter
|
||||||
id: String -- UUID v4
|
id: String -- UUID v4
|
||||||
title: String
|
title: String
|
||||||
slug: String
|
slug: String
|
||||||
@@ -95,25 +96,29 @@ value PostFrontmatter {
|
|||||||
status: draft | published | archived
|
status: draft | published | archived
|
||||||
author: String? -- Only written if present
|
author: String? -- Only written if present
|
||||||
language: String? -- Only written if present (ISO 639-1)
|
language: String? -- Only written if present (ISO 639-1)
|
||||||
do_not_translate: Boolean -- Only written when true
|
doNotTranslate: Boolean -- Only written when true
|
||||||
template_slug: String? -- Only written if present
|
templateSlug: String? -- Only written if present
|
||||||
created_at: Timestamp -- Unix timestamp in milliseconds
|
createdAt: Timestamp -- Unix timestamp in milliseconds
|
||||||
updated_at: Timestamp -- Unix timestamp in milliseconds
|
updatedAt: Timestamp -- Unix timestamp in milliseconds
|
||||||
published_at: Timestamp? -- Only written if published
|
publishedAt: Timestamp? -- Only written if published
|
||||||
tags: List<String> -- Always written, even if empty
|
tags: List<String> -- Always written, even if empty
|
||||||
categories: List<String> -- Always written, even if empty
|
categories: List<String> -- Always written, even if empty
|
||||||
}
|
}
|
||||||
|
|
||||||
value TranslationFrontmatter {
|
value TranslationFrontmatter {
|
||||||
-- File path: posts/{YYYY}/{MM}/{slug}.{language}.md
|
-- File path: posts/{YYYY}/{MM}/{slug}.{language}.md
|
||||||
-- Translation files only store language-specific metadata.
|
-- Translation files carry their own publication state and timestamps
|
||||||
-- Shared publication state and timestamps are inherited from the
|
-- so that each translation can be rebuilt independently.
|
||||||
-- canonical post file and are not duplicated here.
|
-- All keys serialized as camelCase in YAML frontmatter
|
||||||
id: String -- UUID v4
|
id: String -- UUID v4
|
||||||
translation_for: String -- Canonical post UUID
|
translationFor: String -- Canonical post UUID
|
||||||
language: String -- ISO 639-1 language code
|
language: String -- ISO 639-1 language code
|
||||||
title: String -- Translated title
|
title: String -- Translated title
|
||||||
excerpt: String? -- Only written when the translated excerpt differs
|
excerpt: String? -- Only written when the translated excerpt differs
|
||||||
|
status: draft | published
|
||||||
|
createdAt: Timestamp -- Unix timestamp in milliseconds
|
||||||
|
updatedAt: Timestamp -- Unix timestamp in milliseconds
|
||||||
|
publishedAt: Timestamp -- Canonical post's publishedAt at time of publish
|
||||||
}
|
}
|
||||||
|
|
||||||
surface TranslationFrontmatterSurface {
|
surface TranslationFrontmatterSurface {
|
||||||
@@ -121,10 +126,14 @@ surface TranslationFrontmatterSurface {
|
|||||||
|
|
||||||
exposes:
|
exposes:
|
||||||
frontmatter.id
|
frontmatter.id
|
||||||
frontmatter.translation_for
|
frontmatter.translationFor
|
||||||
frontmatter.language
|
frontmatter.language
|
||||||
frontmatter.title
|
frontmatter.title
|
||||||
frontmatter.excerpt when frontmatter.excerpt != null
|
frontmatter.excerpt when frontmatter.excerpt != null
|
||||||
|
frontmatter.status
|
||||||
|
frontmatter.createdAt
|
||||||
|
frontmatter.updatedAt
|
||||||
|
frontmatter.publishedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant PostFileLayout {
|
invariant PostFileLayout {
|
||||||
@@ -147,9 +156,10 @@ invariant PostTranslationFileLayout {
|
|||||||
lang: t.language)
|
lang: t.language)
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant TranslationFilesInheritCanonicalMetadata {
|
invariant TranslationFrontmatterRoundtrip {
|
||||||
-- Missing status and timestamp fields in translation files are expected.
|
-- Translation files carry status and timestamps explicitly.
|
||||||
-- Rebuild and metadata diff must resolve those values from the canonical post.
|
-- On rebuild, these fields are read back directly; fallback to canonical
|
||||||
|
-- post values applies only when fields are absent (legacy files).
|
||||||
for t in PostTranslations where file_path != "":
|
for t in PostTranslations where file_path != "":
|
||||||
parse_frontmatter(read_file(t.file_path)) = translation_frontmatter_fields(t)
|
parse_frontmatter(read_file(t.file_path)) = translation_frontmatter_fields(t)
|
||||||
}
|
}
|
||||||
@@ -171,11 +181,12 @@ rule WritePostFile {
|
|||||||
value MediaSidecar {
|
value MediaSidecar {
|
||||||
-- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta)
|
-- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta)
|
||||||
-- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext}
|
-- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext}
|
||||||
-- Format: YAML-like key-value (hand-built, not gray-matter frontmatter)
|
-- Format: YAML-like key-value wrapped in --- delimiters (gray-matter style, hand-built serializer)
|
||||||
-- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path
|
-- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path
|
||||||
|
-- All keys serialized as camelCase
|
||||||
id: String -- UUID v4
|
id: String -- UUID v4
|
||||||
original_name: String -- Original uploaded filename
|
originalName: String -- Original uploaded filename
|
||||||
mime_type: String
|
mimeType: String
|
||||||
size: Integer -- Bytes
|
size: Integer -- Bytes
|
||||||
width: Integer?
|
width: Integer?
|
||||||
height: Integer?
|
height: Integer?
|
||||||
@@ -185,8 +196,9 @@ value MediaSidecar {
|
|||||||
author: String? -- Only written if present
|
author: String? -- Only written if present
|
||||||
language: String? -- Only written if present
|
language: String? -- Only written if present
|
||||||
tags: List<String> -- Always written, even if empty
|
tags: List<String> -- Always written, even if empty
|
||||||
created_at: Timestamp
|
linkedPostIds: List<String> -- UUIDs of posts that reference this media
|
||||||
updated_at: Timestamp
|
createdAt: Timestamp
|
||||||
|
updatedAt: Timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant MediaSidecarLayout {
|
invariant MediaSidecarLayout {
|
||||||
@@ -200,14 +212,16 @@ invariant MediaSidecarLayout {
|
|||||||
|
|
||||||
value TemplateFrontmatter {
|
value TemplateFrontmatter {
|
||||||
-- File path: templates/{slug}.liquid
|
-- File path: templates/{slug}.liquid
|
||||||
|
-- All keys serialized as camelCase in YAML frontmatter
|
||||||
id: String -- UUID v4
|
id: String -- UUID v4
|
||||||
|
projectId: String -- Scoped to project
|
||||||
slug: String
|
slug: String
|
||||||
title: String
|
title: String
|
||||||
kind: post | list | not_found | partial
|
kind: post | list | not_found | partial
|
||||||
enabled: Boolean
|
enabled: Boolean
|
||||||
version: Integer
|
version: Integer
|
||||||
created_at: Timestamp
|
createdAt: Timestamp
|
||||||
updated_at: Timestamp
|
updatedAt: Timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
rule WriteTemplateFile {
|
rule WriteTemplateFile {
|
||||||
@@ -227,15 +241,17 @@ rule WriteTemplateFile {
|
|||||||
value ScriptFrontmatter {
|
value ScriptFrontmatter {
|
||||||
-- File path: scripts/{slug}.{extension}
|
-- File path: scripts/{slug}.{extension}
|
||||||
-- YAML frontmatter delimited by --- markers
|
-- YAML frontmatter delimited by --- markers
|
||||||
|
-- All keys serialized as camelCase in YAML frontmatter
|
||||||
id: String -- UUID v4
|
id: String -- UUID v4
|
||||||
|
projectId: String -- Scoped to project
|
||||||
slug: String
|
slug: String
|
||||||
title: String
|
title: String
|
||||||
kind: macro | utility | transform
|
kind: macro | utility | transform
|
||||||
entrypoint: String -- Default: "render" for macros, "main" otherwise
|
entrypoint: String -- Default: "render" for macros, "main" otherwise
|
||||||
enabled: Boolean
|
enabled: Boolean
|
||||||
version: Integer
|
version: Integer
|
||||||
created_at: Timestamp
|
createdAt: Timestamp
|
||||||
updated_at: Timestamp
|
updatedAt: Timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
rule WriteScriptFile {
|
rule WriteScriptFile {
|
||||||
@@ -252,24 +268,20 @@ rule WriteScriptFile {
|
|||||||
-- TAGS FILE FORMAT
|
-- TAGS FILE FORMAT
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
value TagsFile {
|
|
||||||
-- File path: meta/tags.json
|
|
||||||
-- Portable JSON format (no internal IDs)
|
|
||||||
tags: List<TagEntry>
|
|
||||||
}
|
|
||||||
|
|
||||||
value TagEntry {
|
value TagEntry {
|
||||||
|
-- File path: meta/tags.json
|
||||||
|
-- Stored as a bare JSON array (no wrapper object)
|
||||||
|
-- Portable JSON format (no internal IDs), camelCase keys
|
||||||
name: String
|
name: String
|
||||||
color: String?
|
color: String?
|
||||||
post_template_slug: String?
|
postTemplateSlug: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant TagsFileFormat {
|
invariant TagsFileFormat {
|
||||||
-- Tags are stored as a sorted JSON array
|
-- Tags are stored as a bare sorted JSON array
|
||||||
-- Sorted alphabetically by name (case-insensitive)
|
-- Sorted alphabetically by name (case-insensitive)
|
||||||
parse_json(read_file("meta/tags.json")) = {
|
parse_json(read_file("meta/tags.json")) =
|
||||||
tags: sort_by(Tags, t => lowercase(t.name))
|
sort_by(tags, t => lowercase(t.name))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -278,16 +290,17 @@ invariant TagsFileFormat {
|
|||||||
|
|
||||||
value ProjectJson {
|
value ProjectJson {
|
||||||
-- File path: meta/project.json
|
-- File path: meta/project.json
|
||||||
|
-- All keys serialized as camelCase
|
||||||
name: String
|
name: String
|
||||||
description: String?
|
description: String?
|
||||||
public_url: String?
|
publicUrl: String?
|
||||||
main_language: String?
|
mainLanguage: String?
|
||||||
default_author: String?
|
defaultAuthor: String?
|
||||||
max_posts_per_page: Integer
|
maxPostsPerPage: Integer
|
||||||
blogmark_category: String?
|
blogmarkCategory: String?
|
||||||
pico_theme: String?
|
picoTheme: String?
|
||||||
semantic_similarity_enabled: Boolean
|
semanticSimilarityEnabled: Boolean
|
||||||
blog_languages: List<String>
|
blogLanguages: List<String>
|
||||||
}
|
}
|
||||||
|
|
||||||
value CategoriesJson {
|
value CategoriesJson {
|
||||||
@@ -303,18 +316,19 @@ value CategoryMetaJson {
|
|||||||
}
|
}
|
||||||
|
|
||||||
value CategorySettings {
|
value CategorySettings {
|
||||||
render_in_lists: Boolean
|
renderInLists: Boolean
|
||||||
show_title: Boolean
|
showTitle: Boolean
|
||||||
post_template_slug: String?
|
postTemplateSlug: String?
|
||||||
list_template_slug: String?
|
listTemplateSlug: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
value PublishingJson {
|
value PublishingJson {
|
||||||
-- File path: meta/publishing.json
|
-- File path: meta/publishing.json
|
||||||
ssh_host: String?
|
-- All keys serialized as camelCase
|
||||||
ssh_user: String?
|
sshHost: String?
|
||||||
ssh_remote_path: String?
|
sshUser: String?
|
||||||
ssh_mode: scp | rsync
|
sshRemotePath: String?
|
||||||
|
sshMode: scp | rsync
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant MetadataFileLayout {
|
invariant MetadataFileLayout {
|
||||||
@@ -325,7 +339,7 @@ invariant MetadataFileLayout {
|
|||||||
meta/category-meta.json = serialize(CategoryMetaJson)
|
meta/category-meta.json = serialize(CategoryMetaJson)
|
||||||
meta/publishing.json = serialize(PublishingJson)
|
meta/publishing.json = serialize(PublishingJson)
|
||||||
meta/menu.opml = serialize(Menu)
|
meta/menu.opml = serialize(Menu)
|
||||||
meta/tags.json = serialize(TagsFile)
|
meta/tags.json = serialize(List<TagEntry>)
|
||||||
}
|
}
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -341,8 +355,8 @@ value MenuOpml {
|
|||||||
|
|
||||||
value OpmlHeader {
|
value OpmlHeader {
|
||||||
title: String
|
title: String
|
||||||
date_created: Timestamp
|
dateCreated: Timestamp
|
||||||
date_modified: Timestamp
|
dateModified: Timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
value MenuItem {
|
value MenuItem {
|
||||||
@@ -376,6 +390,11 @@ invariant YamlFormatting {
|
|||||||
-- Boolean values are lowercase: true/false
|
-- Boolean values are lowercase: true/false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invariant CamelCaseKeys {
|
||||||
|
-- All serialized keys in YAML frontmatter and JSON metadata use camelCase.
|
||||||
|
-- Entity/DB fields use snake_case internally; the mapping happens at serialization.
|
||||||
|
}
|
||||||
|
|
||||||
invariant AtomicWrites {
|
invariant AtomicWrites {
|
||||||
-- All file writes are atomic
|
-- All file writes are atomic
|
||||||
-- Write to temp file first, then rename
|
-- Write to temp file first, then rename
|
||||||
@@ -390,7 +409,7 @@ invariant RequiredPostFields {
|
|||||||
-- These fields are ALWAYS written for posts
|
-- These fields are ALWAYS written for posts
|
||||||
for p in Posts:
|
for p in Posts:
|
||||||
required_fields(p) = {
|
required_fields(p) = {
|
||||||
id, title, slug, status, created_at, updated_at,
|
id, title, slug, status, createdAt, updatedAt,
|
||||||
tags, categories
|
tags, categories
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,9 +418,9 @@ invariant ConditionalPostFields {
|
|||||||
-- These fields are ONLY written if truthy
|
-- These fields are ONLY written if truthy
|
||||||
for p in Posts:
|
for p in Posts:
|
||||||
conditional_fields(p) = {
|
conditional_fields(p) = {
|
||||||
excerpt, author, language, template_slug, published_at
|
excerpt, author, language, templateSlug, publishedAt
|
||||||
}
|
}
|
||||||
-- do_not_translate is only written when true
|
-- doNotTranslate is only written when true
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant RequiredMediaFields {
|
invariant RequiredMediaFields {
|
||||||
@@ -409,8 +428,8 @@ invariant RequiredMediaFields {
|
|||||||
-- Note: 'filename' is NOT a sidecar field — it is the binary path itself
|
-- Note: 'filename' is NOT a sidecar field — it is the binary path itself
|
||||||
for m in Media:
|
for m in Media:
|
||||||
required_fields(m) = {
|
required_fields(m) = {
|
||||||
id, original_name, mime_type, size,
|
id, originalName, mimeType, size,
|
||||||
created_at, updated_at, tags
|
createdAt, updatedAt, tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,23 @@ surface GenerationStatusSurface {
|
|||||||
generation.generated_files.count
|
generation.generated_files.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invariant GenerationPublishedOnly {
|
||||||
|
-- Generation renders the *published* state of the blog, never draft content.
|
||||||
|
--
|
||||||
|
-- Post universe: posts that have a published .md file on disk.
|
||||||
|
-- This includes status=published posts and status=draft posts that were
|
||||||
|
-- previously published (they still have a file_path with last-published content).
|
||||||
|
-- Posts that have never been published (no file_path) are excluded entirely.
|
||||||
|
--
|
||||||
|
-- Content source: always the .md file on disk (the last-published snapshot).
|
||||||
|
-- The DB content field (which holds draft edits) is never read during generation.
|
||||||
|
-- Snapshots set content=nil to ensure file-based resolution.
|
||||||
|
--
|
||||||
|
-- Contrast with preview (see preview.allium PreviewDraftOverlay):
|
||||||
|
-- Preview includes all drafts and prefers DB content over file content,
|
||||||
|
-- giving the author a live view of unpublished edits.
|
||||||
|
}
|
||||||
|
|
||||||
invariant IncrementalByContentHash {
|
invariant IncrementalByContentHash {
|
||||||
-- Files are only written when content_hash changes
|
-- Files are only written when content_hash changes
|
||||||
-- generatedFileHashes table tracks (projectId, relativePath, contentHash)
|
-- generatedFileHashes table tracks (projectId, relativePath, contentHash)
|
||||||
@@ -143,19 +160,39 @@ rule GenerateTagPages {
|
|||||||
when: GenerateSiteRequested(generation)
|
when: GenerateSiteRequested(generation)
|
||||||
requires: tag in generation.sections
|
requires: tag in generation.sections
|
||||||
for t in Tags where post_count > 0:
|
for t in Tags where post_count > 0:
|
||||||
ensures: FileGenerated(format("tag/{slug}/index.html", slug: slugify(t.name)))
|
let slug = slugify(t.name)
|
||||||
|
let page_count = ceil(posts_with_tag(t).count / generation.max_posts_per_page)
|
||||||
|
ensures: FileGenerated(format("tag/{slug}/index.html", slug: slug))
|
||||||
|
for page in page_range(2, page_count):
|
||||||
|
ensures: FileGenerated(format("tag/{slug}/page/{page}/index.html",
|
||||||
|
slug: slug, page: page))
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Date section: year and month archives
|
-- Date section: year, month, and day archives
|
||||||
|
|
||||||
rule GenerateDateArchivePages {
|
rule GenerateDateArchivePages {
|
||||||
when: GenerateSiteRequested(generation)
|
when: GenerateSiteRequested(generation)
|
||||||
requires: date in generation.sections
|
requires: date in generation.sections
|
||||||
for year in distinct_years(Posts):
|
for year in distinct_years(Posts):
|
||||||
|
let yp = ceil(posts_in_year(year).count / generation.max_posts_per_page)
|
||||||
ensures: FileGenerated(format("{year}/index.html", year: year))
|
ensures: FileGenerated(format("{year}/index.html", year: year))
|
||||||
|
for page in page_range(2, yp):
|
||||||
|
ensures: FileGenerated(format("{year}/page/{page}/index.html",
|
||||||
|
year: year, page: page))
|
||||||
for month in distinct_months(Posts, year):
|
for month in distinct_months(Posts, year):
|
||||||
|
let mp = ceil(posts_in_month(year, month).count / generation.max_posts_per_page)
|
||||||
ensures: FileGenerated(format("{year}/{month}/index.html",
|
ensures: FileGenerated(format("{year}/{month}/index.html",
|
||||||
year: year, month: month))
|
year: year, month: month))
|
||||||
|
for page in page_range(2, mp):
|
||||||
|
ensures: FileGenerated(format("{year}/{month}/page/{page}/index.html",
|
||||||
|
year: year, month: month, page: page))
|
||||||
|
for day in distinct_days(Posts, year, month):
|
||||||
|
let dp = ceil(posts_in_day(year, month, day).count / generation.max_posts_per_page)
|
||||||
|
ensures: FileGenerated(format("{year}/{month}/{day}/index.html",
|
||||||
|
year: year, month: month, day: day))
|
||||||
|
for page in page_range(2, dp):
|
||||||
|
ensures: FileGenerated(format("{year}/{month}/{day}/page/{page}/index.html",
|
||||||
|
year: year, month: month, day: day, page: page))
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Template rendering context
|
-- Template rendering context
|
||||||
|
|||||||
@@ -203,3 +203,27 @@ invariant SidecarRoundtrip {
|
|||||||
parse_sidecar(m.sidecar_path).caption = m.caption
|
parse_sidecar(m.sidecar_path).caption = m.caption
|
||||||
parse_sidecar(m.sidecar_path).tags = m.tags
|
parse_sidecar(m.sidecar_path).tags = m.tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rule BatchImportProcessLinkImages {
|
||||||
|
when: BatchImportImagesRequested(project, post, file_paths, language)
|
||||||
|
requires: not OfflineMode
|
||||||
|
for source_path in file_paths where is_image(source_path):
|
||||||
|
let media = ImportMedia(source_path, project)
|
||||||
|
let analysis = AnalyzeImage(media, language)
|
||||||
|
ensures: MediaUpdated(media, analysis)
|
||||||
|
ensures: PostMediaLinked(media, post)
|
||||||
|
@guidance
|
||||||
|
-- Triggered from post editor quick action "Add Gallery Images".
|
||||||
|
-- AI results auto-applied without user confirmation.
|
||||||
|
-- After metadata is set, media is auto-translated to all configured blog languages.
|
||||||
|
-- Non-image files skipped entirely.
|
||||||
|
-- Concurrency limit from project metadata image_import_concurrency (default 4, min 1, max 8).
|
||||||
|
-- Toast per completed image + final summary toast.
|
||||||
|
-- On completion: [[gallery]] macro inserted into post content and post editor refreshed.
|
||||||
|
}
|
||||||
|
|
||||||
|
config {
|
||||||
|
batch_image_import_concurrency_default: Integer = 4
|
||||||
|
batch_image_import_concurrency_min: Integer = 1
|
||||||
|
batch_image_import_concurrency_max: Integer = 8
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
-- allium: 1
|
-- allium: 1
|
||||||
-- bDS Navigation Menu
|
-- bDS Navigation Menu
|
||||||
-- Scope: core (read for rendering), extension Bucket F (menu editor UI)
|
-- Scope: core (read for rendering), extension Bucket F (menu editor UI)
|
||||||
-- Distilled from: src/main/engine/MenuEngine.ts
|
-- File-only model: no DB table. Loaded from meta/menu.opml into a
|
||||||
|
-- transient value, mutated in memory, written back to OPML on save.
|
||||||
|
|
||||||
surface MenuManagementSurface {
|
surface MenuManagementSurface {
|
||||||
facing _: MenuOperator
|
facing _: MenuOperator
|
||||||
|
|
||||||
provides:
|
provides:
|
||||||
UpdateMenuRequested(menu, items)
|
MenuLoadRequested(project_id)
|
||||||
|
UpdateMenuRequested(items)
|
||||||
|
SyncMenuFromFilesystemRequested(project_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
value MenuItem {
|
value MenuItem {
|
||||||
kind: page | submenu | category_archive | home
|
kind: page | submenu | category_archive | home
|
||||||
label: String
|
label: String
|
||||||
slug: String?
|
slug: String? -- pageSlug for page/home, categoryName for category_archive
|
||||||
children: List<MenuItem>? -- only for submenu kind
|
children: List<MenuItem>? -- present only for submenu kind
|
||||||
}
|
}
|
||||||
|
|
||||||
entity Menu {
|
value Menu {
|
||||||
items: List<MenuItem>
|
items: List<MenuItem>
|
||||||
|
|
||||||
-- Derived
|
-- Derived
|
||||||
home_items: items where kind = home
|
home_entry: items.first -- always home after normalization
|
||||||
home_entry: home_items.first
|
|
||||||
}
|
}
|
||||||
|
|
||||||
surface MenuSurface {
|
surface MenuSurface {
|
||||||
@@ -30,27 +32,42 @@ surface MenuSurface {
|
|||||||
|
|
||||||
exposes:
|
exposes:
|
||||||
menu.items.count
|
menu.items.count
|
||||||
menu.home_items.count
|
|
||||||
menu.home_entry.label
|
menu.home_entry.label
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant HomeAlwaysPresent {
|
invariant HomeAlwaysFirst {
|
||||||
-- The menu always has a Home entry, extracted and prepended
|
-- Normalization guarantees home is always the first item.
|
||||||
|
-- UpdateMenu strips any home entries from input, then prepends one.
|
||||||
for menu in Menus:
|
for menu in Menus:
|
||||||
menu.items.first.kind = home
|
menu.items.first.kind = home
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant MenuPersistedAsOpml {
|
invariant MenuPersistedAsOpml {
|
||||||
-- meta/menu.opml is the canonical storage format
|
-- meta/menu.opml is the sole persistent store (no DB table).
|
||||||
-- Uses OPML with outline elements for each item
|
-- OPML outline attributes: text (label), type (kind),
|
||||||
|
-- pageSlug (slug for page/home), categoryName (slug for category_archive).
|
||||||
|
-- Nested <outline> elements represent submenu children.
|
||||||
parse_opml(read_file("meta/menu.opml")) = menu.items
|
parse_opml(read_file("meta/menu.opml")) = menu.items
|
||||||
}
|
}
|
||||||
|
|
||||||
rule UpdateMenu {
|
rule LoadMenu {
|
||||||
when: UpdateMenuRequested(menu, items)
|
when: MenuLoadRequested(project_id)
|
||||||
-- Normalizes Home entry: extracts from items, prepends
|
-- Reads meta/menu.opml; if file missing, returns default (home-only) menu.
|
||||||
let without_home = items where kind != home
|
-- Normalizes: strips home entries from body, prepends canonical home.
|
||||||
let home = MenuItem{kind: home, label: "Home"}
|
ensures: MenuLoaded(project_id, normalize(parse_opml_or_empty(project_id)))
|
||||||
ensures: menu.items = build_menu_items(home, without_home)
|
}
|
||||||
ensures: MenuFileWritten(menu)
|
|
||||||
|
rule UpdateMenu {
|
||||||
|
when: UpdateMenuRequested(items)
|
||||||
|
-- Normalizes Home entry: strips all home items, prepends canonical home.
|
||||||
|
-- Writes normalized menu back to meta/menu.opml.
|
||||||
|
let without_home = items where kind != home
|
||||||
|
ensures: MenuFileWritten(normalize(without_home))
|
||||||
|
}
|
||||||
|
|
||||||
|
rule SyncMenuFromFilesystem {
|
||||||
|
when: SyncMenuFromFilesystemRequested(project_id)
|
||||||
|
-- Reloads menu from OPML, normalizes, writes back (round-trip repair).
|
||||||
|
ensures: MenuLoaded(project_id, _)
|
||||||
|
ensures: MenuFileWritten(_)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ value Slug {
|
|||||||
-- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens
|
-- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens
|
||||||
-- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used.
|
-- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used.
|
||||||
-- Verify transliteration matches the established bDS behaviour for this set.
|
-- Verify transliteration matches the established bDS behaviour for this set.
|
||||||
-- Uniqueness: tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp}
|
-- Uniqueness: tries base, then {slug}-2, {slug}-3, … (unbounded numeric suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
value PostFilePath {
|
value PostFilePath {
|
||||||
|
|||||||
@@ -49,18 +49,24 @@ rule StopPreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
-- Route resolution
|
-- Route resolution
|
||||||
|
-- Preview renders all posts (published + draft) on-demand via Liquid templates.
|
||||||
|
-- Content priority: DB content (draft edits) over published .md file content.
|
||||||
|
-- See invariant PreviewDraftOverlay below.
|
||||||
|
|
||||||
rule ServePostPreview {
|
rule ServePostPreview {
|
||||||
when: PreviewRequest(path)
|
when: PreviewRequest(path)
|
||||||
requires: is_post_path(path)
|
requires: is_post_path(path)
|
||||||
-- path matches "/{yyyy}/{mm}/{dd}/{slug}"
|
-- path matches "/{yyyy}/{mm}/{dd}/{slug}"
|
||||||
-- Renders post via Liquid template with full PageRenderer context
|
-- Finds post by slug+date regardless of status (published or draft).
|
||||||
|
-- Content resolved via editor_body: DB content if present, else .md file.
|
||||||
|
-- Renders via Liquid template with full PageRenderer context.
|
||||||
ensures: PreviewResponse(rendered_html)
|
ensures: PreviewResponse(rendered_html)
|
||||||
}
|
}
|
||||||
|
|
||||||
rule ServeDraftPreview {
|
rule ServeDraftPreview {
|
||||||
when: PreviewDraftRequest(path, post_id)
|
when: PreviewDraftRequest(path, post_id)
|
||||||
-- Renders draft content (from DB, not filesystem)
|
-- Explicit draft preview by post_id (used by editor preview pane).
|
||||||
|
-- Renders draft content (from DB, not filesystem).
|
||||||
ensures: PreviewResponse(rendered_html)
|
ensures: PreviewResponse(rendered_html)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +74,7 @@ rule ServeArchivePreview {
|
|||||||
when: PreviewRequest(path)
|
when: PreviewRequest(path)
|
||||||
requires: is_archive_path(path)
|
requires: is_archive_path(path)
|
||||||
-- Category, tag, date archives with pagination
|
-- Category, tag, date archives with pagination
|
||||||
|
-- Includes both published and draft posts in listings.
|
||||||
ensures: PreviewResponse(rendered_html)
|
ensures: PreviewResponse(rendered_html)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +99,28 @@ rule ServeLanguagePrefixedRoute {
|
|||||||
ensures: PreviewResponse(translated_html)
|
ensures: PreviewResponse(translated_html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invariant PreviewDraftOverlay {
|
||||||
|
-- Preview is the draft workspace: it shows what the blog *will* look like,
|
||||||
|
-- not what it currently looks like on the published site.
|
||||||
|
--
|
||||||
|
-- Post universe: all posts with status in {published, draft}.
|
||||||
|
-- Archived posts are excluded.
|
||||||
|
--
|
||||||
|
-- Content priority (per post):
|
||||||
|
-- 1. DB content field (draft edits not yet published) → used when non-nil
|
||||||
|
-- 2. Published .md file (last-published snapshot) → used when DB content is nil
|
||||||
|
-- 3. Empty string → fallback if neither exists
|
||||||
|
--
|
||||||
|
-- This means:
|
||||||
|
-- - A purely draft post (never published) renders from DB content.
|
||||||
|
-- - A published-then-edited post renders from DB content (the draft edits).
|
||||||
|
-- - A published post with no pending edits renders from its .md file.
|
||||||
|
--
|
||||||
|
-- Contrast with generation (see generation.allium GenerationPublishedOnly):
|
||||||
|
-- Generation uses *only* published .md file content, never DB draft content,
|
||||||
|
-- and excludes posts that have never been published.
|
||||||
|
}
|
||||||
|
|
||||||
invariant ThemeSwitching {
|
invariant ThemeSwitching {
|
||||||
-- Preview supports live theme/mode switching via query params
|
-- Preview supports live theme/mode switching via query params
|
||||||
-- ?theme=amber&mode=dark etc.
|
-- ?theme=amber&mode=dark etc.
|
||||||
|
|||||||
@@ -279,7 +279,6 @@ entity AiModel {
|
|||||||
max_output_tokens: Integer
|
max_output_tokens: Integer
|
||||||
interleaved: String? -- interleaved capability descriptor
|
interleaved: String? -- interleaved capability descriptor
|
||||||
status: String? -- active | deprecated | preview
|
status: String? -- active | deprecated | preview
|
||||||
provider_package_ref: String? -- provider-specific legacy package reference
|
|
||||||
updated_at: Timestamp
|
updated_at: Timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +287,7 @@ entity AiModelModality {
|
|||||||
provider: AiProvider
|
provider: AiProvider
|
||||||
model_id: String
|
model_id: String
|
||||||
direction: String -- "input" | "output"
|
direction: String -- "input" | "output"
|
||||||
modality: String -- "text" | "image" | "audio" | "video"
|
modality: String -- "text" | "image" | "audio" | "file" | "tool"
|
||||||
}
|
}
|
||||||
|
|
||||||
entity AiCatalogMeta {
|
entity AiCatalogMeta {
|
||||||
@@ -552,7 +551,6 @@ surface AiModelRecordSurface {
|
|||||||
model.max_output_tokens
|
model.max_output_tokens
|
||||||
model.interleaved when model.interleaved != null
|
model.interleaved when model.interleaved != null
|
||||||
model.status when model.status != null
|
model.status when model.status != null
|
||||||
model.provider_package_ref when model.provider_package_ref != null
|
|
||||||
model.updated_at
|
model.updated_at
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ enum ScriptStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entity Script {
|
entity Script {
|
||||||
|
project_id: String
|
||||||
slug: String
|
slug: String
|
||||||
title: String
|
title: String
|
||||||
kind: macro | utility | transform
|
kind: macro | utility | transform
|
||||||
@@ -63,8 +64,8 @@ surface ScriptManagementSurface {
|
|||||||
facing _: ScriptOperator
|
facing _: ScriptOperator
|
||||||
|
|
||||||
provides:
|
provides:
|
||||||
CreateScriptRequested(title, kind, content, entrypoint)
|
CreateScriptRequested(project, title, kind, content, entrypoint)
|
||||||
CreateAndPublishScriptRequested(title, kind, content, entrypoint)
|
CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
|
||||||
UpdateScriptRequested(script, changes)
|
UpdateScriptRequested(script, changes)
|
||||||
PublishScriptRequested(script)
|
PublishScriptRequested(script)
|
||||||
DeleteScriptRequested(script)
|
DeleteScriptRequested(script)
|
||||||
@@ -113,9 +114,10 @@ surface ScriptRuntimeSurface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
invariant UniqueScriptSlug {
|
invariant UniqueScriptSlug {
|
||||||
|
-- Slug uniqueness is scoped per project, not globally.
|
||||||
for a in Scripts:
|
for a in Scripts:
|
||||||
for b in Scripts:
|
for b in Scripts:
|
||||||
a != b implies a.slug != b.slug
|
a != b and a.project_id = b.project_id implies a.slug != b.slug
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant ScriptFileLayout {
|
invariant ScriptFileLayout {
|
||||||
@@ -125,11 +127,12 @@ invariant ScriptFileLayout {
|
|||||||
-- Script files use standard --- YAML frontmatter
|
-- Script files use standard --- YAML frontmatter
|
||||||
|
|
||||||
rule CreateScript {
|
rule CreateScript {
|
||||||
when: CreateScriptRequested(title, kind, content, entrypoint)
|
when: CreateScriptRequested(project, title, kind, content, entrypoint)
|
||||||
let slug = slugify(title)
|
let slug = slugify(title)
|
||||||
-- Creates a draft script: content stored in DB, no file written yet
|
-- Creates a draft script: content stored in DB, no file written yet
|
||||||
ensures:
|
ensures:
|
||||||
let new_script = Script.created(
|
let new_script = Script.created(
|
||||||
|
project_id: project.id,
|
||||||
slug: slug,
|
slug: slug,
|
||||||
title: title,
|
title: title,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
@@ -160,11 +163,12 @@ rule ReopenPublishedScript {
|
|||||||
rule CreateAndPublishScript {
|
rule CreateAndPublishScript {
|
||||||
-- Alternative creation path: create + immediately publish (file written)
|
-- Alternative creation path: create + immediately publish (file written)
|
||||||
-- Some implementations may expose this as a single user action
|
-- Some implementations may expose this as a single user action
|
||||||
when: CreateAndPublishScriptRequested(title, kind, content, entrypoint)
|
when: CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
|
||||||
let slug = slugify(title)
|
let slug = slugify(title)
|
||||||
requires: ValidateScript(content) = valid
|
requires: ValidateScript(content) = valid
|
||||||
ensures:
|
ensures:
|
||||||
let new_script = Script.created(
|
let new_script = Script.created(
|
||||||
|
project_id: project.id,
|
||||||
slug: slug,
|
slug: slug,
|
||||||
title: title,
|
title: title,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
@@ -234,7 +238,7 @@ rule ExecuteTransform {
|
|||||||
-- Execution uses the same managed job host API contract as other batch
|
-- Execution uses the same managed job host API contract as other batch
|
||||||
-- scripts and may report progress while mass-processing remote or local
|
-- scripts and may report progress while mass-processing remote or local
|
||||||
-- content.
|
-- content.
|
||||||
let transforms = Scripts where kind = transform and enabled = true
|
let transforms = Scripts where project_id = data.project_id and kind = transform and enabled = true
|
||||||
for t in ordered_by(transforms, s => s.updated_at, s => s.slug, s => s.id):
|
for t in ordered_by(transforms, s => s.updated_at, s => s.slug, s => s.id):
|
||||||
requires: t.entrypoint != ""
|
requires: t.entrypoint != ""
|
||||||
ensures: TransformApplied(t, data)
|
ensures: TransformApplied(t, data)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ surface SearchControlSurface {
|
|||||||
|
|
||||||
provides:
|
provides:
|
||||||
SearchPostsRequested(query, filters)
|
SearchPostsRequested(query, filters)
|
||||||
SearchMediaRequested(query)
|
SearchMediaRequested(query, filters)
|
||||||
}
|
}
|
||||||
|
|
||||||
surface SearchIndexRuntimeSurface {
|
surface SearchIndexRuntimeSurface {
|
||||||
@@ -24,8 +24,9 @@ surface SearchIndexRuntimeSurface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
value StemmerLanguage {
|
value StemmerLanguage {
|
||||||
-- Snowball stemmers for 24 languages
|
-- Snowball stemmers via library (Stemex)
|
||||||
-- ISO 639-1 to Snowball mapping
|
-- Languages with a Snowball algorithm get real stemming;
|
||||||
|
-- others pass through unstemmed
|
||||||
-- Applied to both indexing and query processing
|
-- Applied to both indexing and query processing
|
||||||
code: String
|
code: String
|
||||||
}
|
}
|
||||||
@@ -38,19 +39,29 @@ surface StemmerLanguageSurface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entity PostSearchIndex {
|
entity PostSearchIndex {
|
||||||
-- Full-text index projection
|
-- FTS5 virtual table with per-field stemmed columns
|
||||||
-- Indexed fields: title, excerpt, content, tags, categories
|
-- Each field is stemmed independently; translations are
|
||||||
-- Plus all translation titles, excerpts, and content
|
-- stemmed with their own language stemmer and appended
|
||||||
|
-- to the corresponding field
|
||||||
post: post/Post
|
post: post/Post
|
||||||
stemmed_content: String
|
title: String
|
||||||
|
excerpt: String
|
||||||
|
content: String
|
||||||
|
tags: String
|
||||||
|
categories: String
|
||||||
}
|
}
|
||||||
|
|
||||||
entity MediaSearchIndex {
|
entity MediaSearchIndex {
|
||||||
-- Full-text index projection
|
-- FTS5 virtual table with per-field stemmed columns
|
||||||
-- Indexed fields: title, alt, caption, original_name, tags
|
-- Each field is stemmed independently; translations are
|
||||||
-- Plus all translation titles, alts, and captions
|
-- stemmed with their own language stemmer and appended
|
||||||
|
-- to the corresponding field
|
||||||
media: media/Media
|
media: media/Media
|
||||||
stemmed_content: String
|
title: String
|
||||||
|
alt: String
|
||||||
|
caption: String
|
||||||
|
original_name: String
|
||||||
|
tags: String
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant CrossLanguageStemming {
|
invariant CrossLanguageStemming {
|
||||||
@@ -77,42 +88,80 @@ rule SearchPosts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rule SearchMedia {
|
rule SearchMedia {
|
||||||
when: SearchMediaRequested(query)
|
when: SearchMediaRequested(query, filters)
|
||||||
|
-- Full-text search with optional filters:
|
||||||
|
-- language, tags, year, month, date range (from/to)
|
||||||
|
-- Returns paginated results with total count
|
||||||
let stemmed_query = stem(query, detect_language(query))
|
let stemmed_query = stem(query, detect_language(query))
|
||||||
let matched = search_fts(MediaSearchIndex, stemmed_query)
|
let matched = search_fts(MediaSearchIndex, stemmed_query, filters)
|
||||||
ensures: SearchResults(
|
ensures: SearchResults(
|
||||||
media: matched
|
media: matched,
|
||||||
|
total: matched.count,
|
||||||
|
offset: filters.offset,
|
||||||
|
limit: filters.limit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
rule IndexPost {
|
rule IndexPost {
|
||||||
when: SearchIndexUpdated(post)
|
when: SearchIndexUpdated(post)
|
||||||
-- Stems: title + excerpt + content + tags + categories
|
-- Delete-and-reinsert: no in-place update for FTS5 rows
|
||||||
-- Plus all translations' title + excerpt + content
|
-- Each field is stemmed per-language; translations are stemmed
|
||||||
let all_text = concat_post_text(post)
|
-- with their own language stemmer and joined into the same field
|
||||||
-- Concatenates: post.title, post.excerpt, post.content,
|
let lang = post.language
|
||||||
-- join(post.tags, " "), join(post.categories, " "),
|
let translations = post.translations
|
||||||
-- and all translations' title, excerpt, content
|
let title = join_stemmed(
|
||||||
let index_entry = PostSearchIndex{post: post}
|
stem(post.title, lang),
|
||||||
ensures:
|
for t in translations: stem(t.title, t.language)
|
||||||
if exists index_entry:
|
)
|
||||||
index_entry.stemmed_content = stem(all_text)
|
let excerpt = join_stemmed(
|
||||||
else:
|
stem(post.excerpt, lang),
|
||||||
PostSearchIndex.created(post: post, stemmed_content: stem(all_text))
|
for t in translations: stem(t.excerpt, t.language)
|
||||||
|
)
|
||||||
|
let content = join_stemmed(
|
||||||
|
stem(post.content, lang),
|
||||||
|
for t in translations: stem(t.content, t.language)
|
||||||
|
)
|
||||||
|
let tags = stem(join(post.tags, " "), lang)
|
||||||
|
let categories = stem(join(post.categories, " "), lang)
|
||||||
|
ensures: not exists PostSearchIndex{post: post}
|
||||||
|
ensures: PostSearchIndex.created(
|
||||||
|
post: post,
|
||||||
|
title: title,
|
||||||
|
excerpt: excerpt,
|
||||||
|
content: content,
|
||||||
|
tags: tags,
|
||||||
|
categories: categories
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
rule IndexMedia {
|
rule IndexMedia {
|
||||||
when: SearchIndexUpdated(media)
|
when: SearchIndexUpdated(media)
|
||||||
-- Stems: title + alt + caption + original_name + tags
|
-- Delete-and-reinsert: no in-place update for FTS5 rows
|
||||||
-- Plus all translations' title, alt, caption
|
-- Each field is stemmed per-language; translations are stemmed
|
||||||
let all_text = concat_media_text(media)
|
-- with their own language stemmer and joined into the same field
|
||||||
-- Concatenates: media.title, media.alt, media.caption,
|
let lang = media.language
|
||||||
-- media.original_name, join(media.tags, " "),
|
let translations = media.translations
|
||||||
-- and all translations' title, alt, caption
|
let title = join_stemmed(
|
||||||
let index_entry = MediaSearchIndex{media: media}
|
stem(media.title, lang),
|
||||||
ensures:
|
for t in translations: stem(t.title, t.language)
|
||||||
if exists index_entry:
|
)
|
||||||
index_entry.stemmed_content = stem(all_text)
|
let alt = join_stemmed(
|
||||||
else:
|
stem(media.alt, lang),
|
||||||
MediaSearchIndex.created(media: media, stemmed_content: stem(all_text))
|
for t in translations: stem(t.alt, t.language)
|
||||||
|
)
|
||||||
|
let caption = join_stemmed(
|
||||||
|
stem(media.caption, lang),
|
||||||
|
for t in translations: stem(t.caption, t.language)
|
||||||
|
)
|
||||||
|
let original_name = stem(media.original_name, lang)
|
||||||
|
let tags = stem(join(media.tags, " "), lang)
|
||||||
|
ensures: not exists MediaSearchIndex{media: media}
|
||||||
|
ensures: MediaSearchIndex.created(
|
||||||
|
media: media,
|
||||||
|
title: title,
|
||||||
|
alt: alt,
|
||||||
|
caption: caption,
|
||||||
|
original_name: original_name,
|
||||||
|
tags: tags
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -652,6 +652,9 @@ rule ImportListClick {
|
|||||||
|
|
||||||
-- Full git interface, not the SidebarEntityList pattern.
|
-- Full git interface, not the SidebarEntityList pattern.
|
||||||
-- Three possible states: loading, not_a_repo, active_repo.
|
-- Three possible states: loading, not_a_repo, active_repo.
|
||||||
|
-- Backend: BDS.Git provides status, diff, commit_all, history,
|
||||||
|
-- fetch, pull, push, prune_lfs_cache, remote_state, initialize_repo.
|
||||||
|
-- The sidebar must surface these capabilities directly.
|
||||||
|
|
||||||
-- State: not_a_repo
|
-- State: not_a_repo
|
||||||
-- Remote URL text input + "Initialize Git" button.
|
-- Remote URL text input + "Initialize Git" button.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ enum TemplateStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entity Template {
|
entity Template {
|
||||||
|
project_id: String
|
||||||
slug: String
|
slug: String
|
||||||
title: String
|
title: String
|
||||||
kind: post | list | not_found | partial
|
kind: post | list | not_found | partial
|
||||||
@@ -23,8 +24,8 @@ entity Template {
|
|||||||
|
|
||||||
-- Derived
|
-- Derived
|
||||||
content_location: if status = published: file_path else: content
|
content_location: if status = published: file_path else: content
|
||||||
referencing_posts: Posts where template_slug = this.slug
|
referencing_posts: Posts where template_slug = this.slug and project_id = this.project_id
|
||||||
referencing_tags: Tags where post_template_slug = this.slug
|
referencing_tags: Tags where post_template_slug = this.slug and project_id = this.project_id
|
||||||
|
|
||||||
transitions status {
|
transitions status {
|
||||||
draft -> published
|
draft -> published
|
||||||
@@ -36,8 +37,8 @@ surface TemplateManagementSurface {
|
|||||||
facing _: TemplateOperator
|
facing _: TemplateOperator
|
||||||
|
|
||||||
provides:
|
provides:
|
||||||
CreateTemplateRequested(title, kind, content)
|
CreateTemplateRequested(project, title, kind, content)
|
||||||
CreateAndPublishTemplateRequested(title, kind, content)
|
CreateAndPublishTemplateRequested(project, title, kind, content)
|
||||||
UpdateTemplateRequested(template, changes)
|
UpdateTemplateRequested(template, changes)
|
||||||
PublishTemplateRequested(template)
|
PublishTemplateRequested(template)
|
||||||
DeleteTemplateRequested(template)
|
DeleteTemplateRequested(template)
|
||||||
@@ -45,9 +46,10 @@ surface TemplateManagementSurface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
invariant UniqueTemplateSlug {
|
invariant UniqueTemplateSlug {
|
||||||
|
-- Slug uniqueness is scoped per project, not globally.
|
||||||
for a in Templates:
|
for a in Templates:
|
||||||
for b in Templates:
|
for b in Templates:
|
||||||
a != b implies a.slug != b.slug
|
a != b and a.project_id = b.project_id implies a.slug != b.slug
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant TemplateFrontmatter {
|
invariant TemplateFrontmatter {
|
||||||
@@ -85,11 +87,12 @@ invariant RebuildTemplatesIndexesOnlyProjectTemplates {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rule CreateTemplate {
|
rule CreateTemplate {
|
||||||
when: CreateTemplateRequested(title, kind, content)
|
when: CreateTemplateRequested(project, title, kind, content)
|
||||||
let slug = slugify(title)
|
let slug = slugify(title)
|
||||||
-- Creates a draft template: content stored in DB, no file written yet
|
-- Creates a draft template: content stored in DB, no file written yet
|
||||||
ensures:
|
ensures:
|
||||||
let new_template = Template.created(
|
let new_template = Template.created(
|
||||||
|
project_id: project.id,
|
||||||
slug: slug,
|
slug: slug,
|
||||||
title: title,
|
title: title,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
@@ -105,11 +108,12 @@ rule CreateTemplate {
|
|||||||
rule CreateAndPublishTemplate {
|
rule CreateAndPublishTemplate {
|
||||||
-- Alternative creation path: create + immediately publish (file written)
|
-- Alternative creation path: create + immediately publish (file written)
|
||||||
-- Some implementations may expose this as a single user action
|
-- Some implementations may expose this as a single user action
|
||||||
when: CreateAndPublishTemplateRequested(title, kind, content)
|
when: CreateAndPublishTemplateRequested(project, title, kind, content)
|
||||||
let slug = slugify(title)
|
let slug = slugify(title)
|
||||||
requires: ValidateLiquid(content) = valid
|
requires: ValidateLiquid(content) = valid
|
||||||
ensures:
|
ensures:
|
||||||
let new_template = Template.created(
|
let new_template = Template.created(
|
||||||
|
project_id: project.id,
|
||||||
slug: slug,
|
slug: slug,
|
||||||
title: title,
|
title: title,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
|
|||||||
@@ -64,13 +64,16 @@ entity PostTranslation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant TranslationFilesStoreOnlyLanguageSpecificMetadata {
|
invariant TranslationFilesCarryFullMetadata {
|
||||||
-- Translation markdown files persist only fields that differ by language.
|
-- Translation markdown files include status and timestamps alongside
|
||||||
-- Shared metadata such as publication status and timestamps belongs to the
|
-- language-specific fields. This allows each translation to be rebuilt
|
||||||
-- canonical post file and is inherited from the canonical post when
|
-- independently. On rebuild, missing fields fall back to canonical post
|
||||||
-- rebuilding or diffing translation files.
|
-- values for compatibility with legacy files.
|
||||||
for t in PostTranslations where file_path != "":
|
for t in PostTranslations where file_path != "":
|
||||||
translation_file(t).omits_shared_metadata = true
|
translation_file(t).has_fields(
|
||||||
|
id, translation_for, language, title, excerpt?,
|
||||||
|
status, created_at, updated_at, published_at
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
surface PostTranslationSurface {
|
surface PostTranslationSurface {
|
||||||
|
|||||||
@@ -1477,6 +1477,39 @@ defmodule BDS.AITest do
|
|||||||
assert Enum.map(messages, & &1.role) == [:user]
|
assert Enum.map(messages, & &1.role) == [:user]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "get_surface_state and put_surface_state persist and restore surface UI state" do
|
||||||
|
assert {:ok, conversation} = BDS.AI.start_chat(%{title: "Surface State", model: "gpt-4.1"})
|
||||||
|
|
||||||
|
surface_data = %{"msg-1-surface-0" => %{"query" => "hello"}}
|
||||||
|
surface_tabs = %{"msg-1-surface-1" => 2}
|
||||||
|
dismissed = MapSet.new(["msg-1-surface-0"])
|
||||||
|
|
||||||
|
assert {:ok, _state} =
|
||||||
|
BDS.AI.put_surface_state(
|
||||||
|
conversation.id,
|
||||||
|
surface_data,
|
||||||
|
surface_tabs,
|
||||||
|
dismissed
|
||||||
|
)
|
||||||
|
|
||||||
|
loaded = BDS.AI.get_surface_state(conversation.id)
|
||||||
|
assert loaded["surface_data"] == surface_data
|
||||||
|
assert loaded["surface_tabs"] == surface_tabs
|
||||||
|
assert MapSet.new(loaded["dismissed_surfaces"]) == dismissed
|
||||||
|
end
|
||||||
|
|
||||||
|
test "get_surface_state returns empty map for conversation without surface state" do
|
||||||
|
assert {:ok, conversation} = BDS.AI.start_chat(%{title: "No Surface State", model: "gpt-4.1"})
|
||||||
|
|
||||||
|
loaded = BDS.AI.get_surface_state(conversation.id)
|
||||||
|
assert loaded == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "get_surface_state returns empty map for unknown conversation" do
|
||||||
|
loaded = BDS.AI.get_surface_state("nonexistent-id")
|
||||||
|
assert loaded == %{}
|
||||||
|
end
|
||||||
|
|
||||||
defp create_project_fixture(name) do
|
defp create_project_fixture(name) do
|
||||||
temp_dir = Path.join(System.tmp_dir!(), "bds-ai-#{System.unique_integer([:positive])}")
|
temp_dir = Path.join(System.tmp_dir!(), "bds-ai-#{System.unique_integer([:positive])}")
|
||||||
on_exit(fn -> File.rm_rf(temp_dir) end)
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ defmodule BDS.TOCTOU.FileSystemTest do
|
|||||||
|
|
||||||
alias BDS.Rendering.FileSystem, as: TemplateFileSystem
|
alias BDS.Rendering.FileSystem, as: TemplateFileSystem
|
||||||
|
|
||||||
|
setup do
|
||||||
|
tmp_dir = Path.join(System.tmp_dir!(), "bds-toctou-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(tmp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(tmp_dir) end)
|
||||||
|
%{tmp_dir: tmp_dir}
|
||||||
|
end
|
||||||
|
|
||||||
describe "try_read/2 eliminates TOCTOU race" do
|
describe "try_read/2 eliminates TOCTOU race" do
|
||||||
@tag :tmp_dir
|
|
||||||
test "reads file atomically without separate existence check", %{tmp_dir: tmp_dir} do
|
test "reads file atomically without separate existence check", %{tmp_dir: tmp_dir} do
|
||||||
File.write!(Path.join(tmp_dir, "header.liquid"), "HEADER CONTENT")
|
File.write!(Path.join(tmp_dir, "header.liquid"), "HEADER CONTENT")
|
||||||
fs = TemplateFileSystem.new(tmp_dir)
|
fs = TemplateFileSystem.new(tmp_dir)
|
||||||
@@ -12,14 +18,12 @@ defmodule BDS.TOCTOU.FileSystemTest do
|
|||||||
assert {:ok, "HEADER CONTENT"} = TemplateFileSystem.try_read(fs, "header")
|
assert {:ok, "HEADER CONTENT"} = TemplateFileSystem.try_read(fs, "header")
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :tmp_dir
|
|
||||||
test "returns {:error, :enoent} for missing templates", %{tmp_dir: tmp_dir} do
|
test "returns {:error, :enoent} for missing templates", %{tmp_dir: tmp_dir} do
|
||||||
fs = TemplateFileSystem.new(tmp_dir)
|
fs = TemplateFileSystem.new(tmp_dir)
|
||||||
|
|
||||||
assert {:error, :enoent} = TemplateFileSystem.try_read(fs, "nonexistent")
|
assert {:error, :enoent} = TemplateFileSystem.try_read(fs, "nonexistent")
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :tmp_dir
|
|
||||||
test "falls through to next root path when first is missing", %{tmp_dir: tmp_dir} do
|
test "falls through to next root path when first is missing", %{tmp_dir: tmp_dir} do
|
||||||
root_a = Path.join(tmp_dir, "a")
|
root_a = Path.join(tmp_dir, "a")
|
||||||
root_b = Path.join(tmp_dir, "b")
|
root_b = Path.join(tmp_dir, "b")
|
||||||
@@ -32,7 +36,6 @@ defmodule BDS.TOCTOU.FileSystemTest do
|
|||||||
assert {:ok, "FROM B"} = TemplateFileSystem.try_read(fs, "partial")
|
assert {:ok, "FROM B"} = TemplateFileSystem.try_read(fs, "partial")
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :tmp_dir
|
|
||||||
test "first root path wins when both have the template", %{tmp_dir: tmp_dir} do
|
test "first root path wins when both have the template", %{tmp_dir: tmp_dir} do
|
||||||
root_a = Path.join(tmp_dir, "a")
|
root_a = Path.join(tmp_dir, "a")
|
||||||
root_b = Path.join(tmp_dir, "b")
|
root_b = Path.join(tmp_dir, "b")
|
||||||
@@ -46,7 +49,6 @@ defmodule BDS.TOCTOU.FileSystemTest do
|
|||||||
assert {:ok, "FROM A"} = TemplateFileSystem.try_read(fs, "shared")
|
assert {:ok, "FROM A"} = TemplateFileSystem.try_read(fs, "shared")
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :tmp_dir
|
|
||||||
test "file deleted between candidate_paths and try_read does not crash", %{tmp_dir: tmp_dir} do
|
test "file deleted between candidate_paths and try_read does not crash", %{tmp_dir: tmp_dir} do
|
||||||
path = Path.join(tmp_dir, "ephemeral.liquid")
|
path = Path.join(tmp_dir, "ephemeral.liquid")
|
||||||
File.write!(path, "TEMPORARY")
|
File.write!(path, "TEMPORARY")
|
||||||
@@ -60,7 +62,6 @@ defmodule BDS.TOCTOU.FileSystemTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "read_template_file/2 protocol uses atomic read" do
|
describe "read_template_file/2 protocol uses atomic read" do
|
||||||
@tag :tmp_dir
|
|
||||||
test "reads existing template", %{tmp_dir: tmp_dir} do
|
test "reads existing template", %{tmp_dir: tmp_dir} do
|
||||||
File.write!(Path.join(tmp_dir, "footer.liquid"), "FOOTER")
|
File.write!(Path.join(tmp_dir, "footer.liquid"), "FOOTER")
|
||||||
fs = TemplateFileSystem.new(tmp_dir)
|
fs = TemplateFileSystem.new(tmp_dir)
|
||||||
@@ -68,7 +69,6 @@ defmodule BDS.TOCTOU.FileSystemTest do
|
|||||||
assert "FOOTER" = Liquex.FileSystem.read_template_file(fs, "footer")
|
assert "FOOTER" = Liquex.FileSystem.read_template_file(fs, "footer")
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :tmp_dir
|
|
||||||
test "raises on missing template", %{tmp_dir: tmp_dir} do
|
test "raises on missing template", %{tmp_dir: tmp_dir} do
|
||||||
fs = TemplateFileSystem.new(tmp_dir)
|
fs = TemplateFileSystem.new(tmp_dir)
|
||||||
|
|
||||||
|
|||||||
206
test/bds/csm033_batch_inserts_test.exs
Normal file
206
test/bds/csm033_batch_inserts_test.exs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
defmodule BDS.CSM033BatchInsertsTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
describe "source-level: embeddings.ex uses batch inserts instead of Enum.each + individual writes" do
|
||||||
|
setup do
|
||||||
|
source = File.read!("lib/bds/embeddings.ex")
|
||||||
|
%{source: source}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no Enum.each calling sync_post_if_enabled in bulk paths", %{source: source} do
|
||||||
|
refute source =~ "Enum.each(posts, &sync_post_if_enabled",
|
||||||
|
"bulk paths should not use Enum.each with sync_post_if_enabled"
|
||||||
|
|
||||||
|
refute source =~ ~r/Enum\.each\(fn \{post, index\} ->\n\s+sync_post_if_enabled/,
|
||||||
|
"bulk paths should not use Enum.each with sync_post_if_enabled"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "bulk functions use batch_upsert_keys", %{source: source} do
|
||||||
|
assert source =~ "batch_upsert_keys(rows)",
|
||||||
|
"expected batch_upsert_keys to be called with collected rows"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "bulk functions preload keys before the loop", %{source: source} do
|
||||||
|
assert source =~ "preload_keys_by_post_id(project_id)",
|
||||||
|
"expected keys to be preloaded in a single query"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "batch_upsert_keys uses multi-row INSERT with ON CONFLICT upsert", %{source: source} do
|
||||||
|
assert source =~ "INSERT INTO embedding_keys",
|
||||||
|
"expected raw SQL batch INSERT for embedding keys"
|
||||||
|
|
||||||
|
assert source =~ "ON CONFLICT(label) DO UPDATE",
|
||||||
|
"expected ON CONFLICT upsert clause"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "compute_key_data is used instead of individual Repo.insert_or_update", %{source: source} do
|
||||||
|
assert source =~ "compute_key_data(post, existing_key, next_label)",
|
||||||
|
"expected compute_key_data helper for row computation"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "source-level: search.ex already uses batch inserts" do
|
||||||
|
test "batch_insert_post_index uses multi-row VALUES" do
|
||||||
|
source = File.read!("lib/bds/search.ex")
|
||||||
|
assert source =~ "batch_insert_post_index"
|
||||||
|
assert source =~ ~r/INSERT INTO posts_fts.*VALUES.*\#\{placeholders\}/s
|
||||||
|
end
|
||||||
|
|
||||||
|
test "batch_insert_media_index uses multi-row VALUES" do
|
||||||
|
source = File.read!("lib/bds/search.ex")
|
||||||
|
assert source =~ "batch_insert_media_index"
|
||||||
|
assert source =~ ~r/INSERT INTO media_fts.*VALUES.*\#\{placeholders\}/s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "functional: batch operations produce correct results" do
|
||||||
|
defmodule FakeBackend do
|
||||||
|
@behaviour BDS.Embeddings.Backend
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def model_info, do: %{model_id: "fake/test-model", dimensions: 384}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def embed(text, opts), do: BDS.Embeddings.Backends.InApp.embed(text, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||||
|
|
||||||
|
temp_dir =
|
||||||
|
Path.join(System.tmp_dir!(), "bds-csm033-#{System.unique_integer([:positive])}")
|
||||||
|
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "CSM033", data_path: temp_dir})
|
||||||
|
|
||||||
|
previous_config = Application.get_env(:bds, :embeddings)
|
||||||
|
Application.put_env(:bds, :embeddings, backend: FakeBackend)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
if previous_config == nil do
|
||||||
|
Application.delete_env(:bds, :embeddings)
|
||||||
|
else
|
||||||
|
Application.put_env(:bds, :embeddings, previous_config)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
BDS.Metadata.update_project_metadata(project.id, %{
|
||||||
|
semantic_similarity_enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
%{project: project}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "index_unindexed batch-inserts keys for multiple posts", %{project: project} do
|
||||||
|
posts =
|
||||||
|
for i <- 1..5 do
|
||||||
|
{:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Post #{i}",
|
||||||
|
content: "content for post number #{i} with unique words #{:rand.uniform(10000)}",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
post
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, indexed} = BDS.Embeddings.index_unindexed(project.id)
|
||||||
|
assert length(indexed) == 5
|
||||||
|
assert Enum.all?(posts, fn post -> post.id in indexed end)
|
||||||
|
|
||||||
|
keys =
|
||||||
|
BDS.Repo.all(
|
||||||
|
from(k in BDS.Embeddings.Key, where: k.project_id == ^project.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert length(keys) == 5
|
||||||
|
labels = Enum.map(keys, & &1.label) |> Enum.sort()
|
||||||
|
assert labels == Enum.to_list(1..5)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rebuild_project updates stale keys via batch upsert", %{project: project} do
|
||||||
|
{:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Rebuild Target",
|
||||||
|
content: "original content for rebuild test",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
|
||||||
|
|
||||||
|
original_key =
|
||||||
|
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
|
||||||
|
|
||||||
|
{:ok, _post} = BDS.Posts.update_post(post.id, %{content: "completely different content now"})
|
||||||
|
|
||||||
|
{:ok, rebuilt_ids} = BDS.Embeddings.rebuild_project(project.id)
|
||||||
|
assert post.id in rebuilt_ids
|
||||||
|
|
||||||
|
updated_key =
|
||||||
|
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
|
||||||
|
|
||||||
|
assert updated_key.label == original_key.label
|
||||||
|
assert updated_key.content_hash != original_key.content_hash
|
||||||
|
end
|
||||||
|
|
||||||
|
test "repair_posts batch-upserts for specified posts only", %{project: project} do
|
||||||
|
{:ok, post_a} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Repair A",
|
||||||
|
content: "content A",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _post_b} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Repair B",
|
||||||
|
content: "content B",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
|
||||||
|
{:ok, repaired} = BDS.Embeddings.repair_posts(project.id, [post_a.id])
|
||||||
|
assert repaired == [post_a.id]
|
||||||
|
|
||||||
|
keys =
|
||||||
|
BDS.Repo.all(
|
||||||
|
from(k in BDS.Embeddings.Key, where: k.project_id == ^project.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert length(keys) == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "index_unindexed skips posts with matching content hash", %{project: project} do
|
||||||
|
{:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Skip Test",
|
||||||
|
content: "unchanged content for skip test",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
|
||||||
|
|
||||||
|
key_before =
|
||||||
|
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
|
||||||
|
|
||||||
|
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
|
||||||
|
|
||||||
|
key_after =
|
||||||
|
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
|
||||||
|
|
||||||
|
assert key_before.label == key_after.label
|
||||||
|
assert key_before.content_hash == key_after.content_hash
|
||||||
|
assert key_before.vector == key_after.vector
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
107
test/bds/csm034_file_read_bang_test.exs
Normal file
107
test/bds/csm034_file_read_bang_test.exs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
defmodule BDS.CSM034FileReadBangTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
describe "source-level: no File.read! or File.write! in affected files" do
|
||||||
|
test "preview_assets.ex has no File.read!" do
|
||||||
|
source = File.read!("lib/bds/preview_assets.ex")
|
||||||
|
refute source =~ "File.read!", "preview_assets.ex should use File.read, not File.read!"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "templates.ex has no File.read!" do
|
||||||
|
source = File.read!("lib/bds/templates.ex")
|
||||||
|
refute source =~ "File.read!", "templates.ex should use File.read, not File.read!"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "templates.ex has no File.write!" do
|
||||||
|
source = File.read!("lib/bds/templates.ex")
|
||||||
|
refute source =~ "File.write!", "templates.ex should use File.write, not File.write!"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "release_packaging.ex has no File.read! or File.write!" do
|
||||||
|
source = File.read!("lib/bds/release_packaging.ex")
|
||||||
|
refute source =~ "File.read!", "release_packaging.ex should use File.read, not File.read!"
|
||||||
|
refute source =~ "File.write!", "release_packaging.ex should use File.write, not File.write!"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "preview_assets.generated_outputs/0 handles read errors" do
|
||||||
|
test "returns results for readable files, skips unreadable ones" do
|
||||||
|
outputs = BDS.PreviewAssets.generated_outputs()
|
||||||
|
assert is_list(outputs)
|
||||||
|
assert Enum.all?(outputs, fn {path, body} -> is_binary(path) and is_binary(body) end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "templates file error handling" do
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||||
|
temp_dir = Path.join(System.tmp_dir!(), "bds-csm034-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "CSM034", data_path: temp_dir})
|
||||||
|
%{project: project, temp_dir: temp_dir}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rebuild skips unreadable template files without crashing", %{
|
||||||
|
project: project,
|
||||||
|
temp_dir: temp_dir
|
||||||
|
} do
|
||||||
|
templates_dir = Path.join(temp_dir, "templates")
|
||||||
|
File.mkdir_p!(templates_dir)
|
||||||
|
|
||||||
|
File.write!(Path.join(templates_dir, "good.liquid"), """
|
||||||
|
---
|
||||||
|
slug: good
|
||||||
|
kind: post
|
||||||
|
title: Good Template
|
||||||
|
---
|
||||||
|
<p>{{ content }}</p>
|
||||||
|
""")
|
||||||
|
|
||||||
|
File.write!(Path.join(templates_dir, "bad.liquid"), "no frontmatter here")
|
||||||
|
|
||||||
|
assert {:ok, templates} = BDS.Templates.rebuild_templates_from_files(project.id)
|
||||||
|
assert length(templates) == 1
|
||||||
|
assert hd(templates).slug == "good"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sync_template_from_file returns error when file is deleted", %{
|
||||||
|
project: project,
|
||||||
|
temp_dir: temp_dir
|
||||||
|
} do
|
||||||
|
templates_dir = Path.join(temp_dir, "templates")
|
||||||
|
File.mkdir_p!(templates_dir)
|
||||||
|
|
||||||
|
path = Path.join(templates_dir, "ephemeral.liquid")
|
||||||
|
|
||||||
|
File.write!(path, """
|
||||||
|
---
|
||||||
|
slug: ephemeral
|
||||||
|
kind: post
|
||||||
|
title: Ephemeral
|
||||||
|
---
|
||||||
|
<p>{{ content }}</p>
|
||||||
|
""")
|
||||||
|
|
||||||
|
{:ok, _templates} = BDS.Templates.rebuild_templates_from_files(project.id)
|
||||||
|
|
||||||
|
template =
|
||||||
|
BDS.Repo.one(
|
||||||
|
from(t in BDS.Templates.Template,
|
||||||
|
where: t.project_id == ^project.id and t.slug == "ephemeral"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
File.rm!(path)
|
||||||
|
|
||||||
|
assert {:error, :not_found} = BDS.Templates.sync_template_from_file(template.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "import_orphan_template_file returns error for missing file", %{project: project} do
|
||||||
|
assert {:error, :not_found} =
|
||||||
|
BDS.Templates.import_orphan_template_file(project.id, "templates/ghost.liquid")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
86
test/bds/csm035_process_dict_test.exs
Normal file
86
test/bds/csm035_process_dict_test.exs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
defmodule BDS.CSM035ProcessDictTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias BDS.Desktop.UILocale
|
||||||
|
|
||||||
|
describe "source-level: no raw Process.put/get for :bds_ui_locale outside ui_locale.ex" do
|
||||||
|
test "no direct Process.put(:bds_ui_locale, ...) outside ui_locale.ex" do
|
||||||
|
elixir_files =
|
||||||
|
Path.wildcard("lib/**/*.ex")
|
||||||
|
|> Enum.reject(&(&1 == "lib/bds/desktop/ui_locale.ex"))
|
||||||
|
|
||||||
|
violations =
|
||||||
|
Enum.filter(elixir_files, fn path ->
|
||||||
|
source = File.read!(path)
|
||||||
|
source =~ "Process.put(:bds_ui_locale" or source =~ "Process.put(@key"
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert violations == [],
|
||||||
|
"Raw Process.put(:bds_ui_locale) found outside ui_locale.ex: #{inspect(violations)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no direct Process.get(:bds_ui_locale, ...) outside ui_locale.ex" do
|
||||||
|
elixir_files =
|
||||||
|
Path.wildcard("lib/**/*.ex")
|
||||||
|
|> Enum.reject(&(&1 == "lib/bds/desktop/ui_locale.ex"))
|
||||||
|
|
||||||
|
violations =
|
||||||
|
Enum.filter(elixir_files, fn path ->
|
||||||
|
source = File.read!(path)
|
||||||
|
source =~ "Process.get(:bds_ui_locale" or source =~ "Process.delete(:bds_ui_locale"
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert violations == [],
|
||||||
|
"Raw Process.get/delete(:bds_ui_locale) found outside ui_locale.ex: #{inspect(violations)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "source-level: render boundaries call UILocale.put before template evaluation" do
|
||||||
|
test "ShellLive.render/1 calls UILocale.put before index(assigns)" do
|
||||||
|
source = File.read!("lib/bds/desktop/shell_live.ex")
|
||||||
|
render_match = Regex.run(~r/def render\(assigns\).*?\n(.*?)\n(.*?)\n/s, source)
|
||||||
|
assert render_match, "could not find render/1 in shell_live.ex"
|
||||||
|
[_, first_line | _] = render_match
|
||||||
|
assert first_line =~ "UILocale.put", "render/1 must call UILocale.put on its first line"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sidebar_content/1 calls UILocale.put before HEEx" do
|
||||||
|
source = File.read!("lib/bds/desktop/shell_live/sidebar_components.ex")
|
||||||
|
match = Regex.run(~r/def sidebar_content\(assigns\).*?\n(.*?)\n/s, source)
|
||||||
|
assert match, "could not find sidebar_content/1"
|
||||||
|
[_, first_line | _] = match
|
||||||
|
assert first_line =~ "UILocale.put", "sidebar_content/1 must call UILocale.put on its first line"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "MenuBar.mount/1 calls UILocale.put" do
|
||||||
|
source = File.read!("lib/bds/desktop/menu_bar.ex")
|
||||||
|
match = Regex.run(~r/def mount\(menu\).*?\n(.*?)\n/s, source)
|
||||||
|
assert match, "could not find mount/1 in menu_bar.ex"
|
||||||
|
[_, first_line | _] = match
|
||||||
|
assert first_line =~ "UILocale.put", "MenuBar.mount/1 must call UILocale.put on its first line"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "UILocale functional behavior" do
|
||||||
|
test "put/1 sets locale readable by current/0" do
|
||||||
|
UILocale.put("de")
|
||||||
|
assert UILocale.current() == "de"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with_locale/2 restores previous locale after block" do
|
||||||
|
UILocale.put("en")
|
||||||
|
UILocale.with_locale("fr", fn -> assert UILocale.current() == "fr" end)
|
||||||
|
assert UILocale.current() == "en"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with_locale/2 restores nil when no prior locale was set" do
|
||||||
|
Process.delete(:bds_ui_locale)
|
||||||
|
UILocale.with_locale("it", fn -> assert UILocale.current() == "it" end)
|
||||||
|
assert UILocale.current() == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "put/1 returns :ok" do
|
||||||
|
assert UILocale.put("en") == :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
45
test/bds/csm036_impl_true_test.exs
Normal file
45
test/bds/csm036_impl_true_test.exs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
defmodule BDS.CSM036ImplTrueTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
@publishing_source File.read!("lib/bds/publishing.ex")
|
||||||
|
|
||||||
|
describe "CSM-036: @impl true on GenServer callbacks" do
|
||||||
|
test "every handle_call clause has @impl true" do
|
||||||
|
lines = String.split(@publishing_source, "\n")
|
||||||
|
|
||||||
|
handle_call_lines =
|
||||||
|
lines
|
||||||
|
|> Enum.with_index(1)
|
||||||
|
|> Enum.filter(fn {line, _idx} ->
|
||||||
|
String.contains?(line, "def handle_call(")
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert length(handle_call_lines) >= 5, "expected at least 5 handle_call clauses"
|
||||||
|
|
||||||
|
for {_line, idx} <- handle_call_lines do
|
||||||
|
preceding = Enum.at(lines, idx - 2)
|
||||||
|
|
||||||
|
assert String.contains?(preceding, "@impl true"),
|
||||||
|
"handle_call at line #{idx} missing @impl true (preceding line: #{inspect(preceding)})"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "no handle_cast, handle_info, or terminate without @impl true" do
|
||||||
|
lines = String.split(@publishing_source, "\n")
|
||||||
|
|
||||||
|
callback_lines =
|
||||||
|
lines
|
||||||
|
|> Enum.with_index(1)
|
||||||
|
|> Enum.filter(fn {line, _idx} ->
|
||||||
|
Regex.match?(~r/^\s+def (handle_cast|handle_info|terminate)\(/, line)
|
||||||
|
end)
|
||||||
|
|
||||||
|
for {_line, idx} <- callback_lines do
|
||||||
|
preceding = Enum.at(lines, idx - 2)
|
||||||
|
|
||||||
|
assert String.contains?(preceding, "@impl true"),
|
||||||
|
"callback at line #{idx} missing @impl true"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1337,6 +1337,142 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert saved_post.excerpt == "Saved excerpt"
|
assert saved_post.excerpt == "Saved excerpt"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "post editor auto-saves after idle timer fires", %{project: project} do
|
||||||
|
{:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Auto-save Draft",
|
||||||
|
content: "Original body",
|
||||||
|
excerpt: "Original excerpt"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
_html =
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
_html =
|
||||||
|
view
|
||||||
|
|> form("[data-testid='post-editor-form']", %{
|
||||||
|
post_editor: %{
|
||||||
|
title: "Auto-saved Title",
|
||||||
|
content: "Auto-saved body",
|
||||||
|
excerpt: "Auto-saved excerpt",
|
||||||
|
tags: "",
|
||||||
|
categories: "",
|
||||||
|
author: "",
|
||||||
|
language: "en",
|
||||||
|
do_not_translate: "false"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
send_and_await(view, {:auto_save_fire, :post, post.id})
|
||||||
|
_html = render(view)
|
||||||
|
|
||||||
|
saved_post = Posts.get_post!(post.id)
|
||||||
|
assert saved_post.title == "Auto-saved Title"
|
||||||
|
assert saved_post.content == "Auto-saved body"
|
||||||
|
assert saved_post.excerpt == "Auto-saved excerpt"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "post editor auto-save timer fires and persists after delay", %{project: project} do
|
||||||
|
Application.put_env(:bds, :auto_save_delay, 100)
|
||||||
|
on_exit(fn -> Application.delete_env(:bds, :auto_save_delay) end)
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Timer Draft",
|
||||||
|
content: "Body",
|
||||||
|
excerpt: ""
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
_html =
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
_html =
|
||||||
|
view
|
||||||
|
|> form("[data-testid='post-editor-form']", %{
|
||||||
|
post_editor: %{
|
||||||
|
title: "Timer Changed Title",
|
||||||
|
content: "Body",
|
||||||
|
excerpt: "",
|
||||||
|
tags: "",
|
||||||
|
categories: "",
|
||||||
|
author: "",
|
||||||
|
language: "en",
|
||||||
|
do_not_translate: "false"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
Process.sleep(200)
|
||||||
|
_html = render(view)
|
||||||
|
|
||||||
|
saved_post = Posts.get_post!(post.id)
|
||||||
|
assert saved_post.title == "Timer Changed Title"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "post editor auto-saves dirty content on tab switch", %{project: project} do
|
||||||
|
{:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Tab-switch Draft",
|
||||||
|
content: "Original body",
|
||||||
|
excerpt: ""
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
_html =
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
_html =
|
||||||
|
view
|
||||||
|
|> form("[data-testid='post-editor-form']", %{
|
||||||
|
post_editor: %{
|
||||||
|
title: "Unsaved Tab Title",
|
||||||
|
content: "Unsaved body",
|
||||||
|
excerpt: "",
|
||||||
|
tags: "",
|
||||||
|
categories: "",
|
||||||
|
author: "",
|
||||||
|
language: "en",
|
||||||
|
do_not_translate: "false"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
_html =
|
||||||
|
render_click(view, "select_tab", %{
|
||||||
|
"type" => "dashboard",
|
||||||
|
"id" => "dashboard"
|
||||||
|
})
|
||||||
|
|
||||||
|
_html = render(view)
|
||||||
|
saved_post = Posts.get_post!(post.id)
|
||||||
|
assert saved_post.title == "Unsaved Tab Title"
|
||||||
|
assert saved_post.content == "Unsaved body"
|
||||||
|
end
|
||||||
|
|
||||||
test "menu editor adds a submenu, nests an entry, and saves the opml", %{
|
test "menu editor adds a submenu, nests an entry, and saves the opml", %{
|
||||||
project: project,
|
project: project,
|
||||||
temp_dir: temp_dir
|
temp_dir: temp_dir
|
||||||
@@ -3998,7 +4134,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
|> element("[data-testid='chat-send-button']")
|
|> element("[data-testid='chat-send-button']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
send(view.pid, {
|
send_and_await(view, {
|
||||||
:chat_tool_call,
|
:chat_tool_call,
|
||||||
conversation.id,
|
conversation.id,
|
||||||
%{
|
%{
|
||||||
@@ -4043,6 +4179,87 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert live_js =~ "this.syncExpandedSurfaces();"
|
assert live_js =~ "this.syncExpandedSurfaces();"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "chat editor restores dismissed surfaces from persisted surface state when reopening a chat" do
|
||||||
|
assert {:ok, conversation} = AI.start_chat(%{title: "Reopen Chat", model: "gpt-4.1"})
|
||||||
|
|
||||||
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
|
Repo.insert!(
|
||||||
|
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
role: :user,
|
||||||
|
content: "Show me two cards",
|
||||||
|
created_at: now
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
Repo.insert!(
|
||||||
|
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
role: :assistant,
|
||||||
|
content: "Here are two cards.",
|
||||||
|
tool_calls:
|
||||||
|
Jason.encode!([
|
||||||
|
%{
|
||||||
|
"id" => "call-card-a",
|
||||||
|
"name" => "render_card",
|
||||||
|
"arguments" => %{
|
||||||
|
"title" => "UniqueTitleAlpha",
|
||||||
|
"body" => "First card alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"id" => "call-card-b",
|
||||||
|
"name" => "render_card",
|
||||||
|
"arguments" => %{
|
||||||
|
"title" => "UniqueTitleBeta",
|
||||||
|
"body" => "Second card beta"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
created_at: now + 1
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "chat",
|
||||||
|
"id" => conversation.id,
|
||||||
|
"title" => conversation.title,
|
||||||
|
"subtitle" => conversation.model || "chat"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 2
|
||||||
|
|
||||||
|
surface_id_a = Regex.run(~r/id="([^"]+-surface-0)"/, html) |> Enum.at(1)
|
||||||
|
|
||||||
|
dismissed_html =
|
||||||
|
view
|
||||||
|
|> element("button[phx-value-surface-id='#{surface_id_a}']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert length(:binary.matches(dismissed_html, ~s(data-testid="chat-inline-surface"))) == 1
|
||||||
|
|
||||||
|
persisted = AI.get_surface_state(conversation.id)
|
||||||
|
assert MapSet.new(persisted["dismissed_surfaces"]) == MapSet.new([surface_id_a])
|
||||||
|
|
||||||
|
{:ok, view2, _html2} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
html2 =
|
||||||
|
render_click(view2, "pin_sidebar_item", %{
|
||||||
|
"route" => "chat",
|
||||||
|
"id" => conversation.id,
|
||||||
|
"title" => conversation.title,
|
||||||
|
"subtitle" => conversation.model || "chat"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert length(:binary.matches(html2, ~s(data-testid="chat-inline-surface"))) == 1
|
||||||
|
assert html2 =~ "UniqueTitleBeta"
|
||||||
|
refute html2 =~ ~r/id="#{Regex.escape(surface_id_a)}"/
|
||||||
|
end
|
||||||
|
|
||||||
test "chat editor folds tool-only assistant steps into the final assistant answer" do
|
test "chat editor folds tool-only assistant steps into the final assistant answer" do
|
||||||
assert {:ok, conversation} = AI.start_chat(%{title: "Tool Chat", model: "gpt-4.1"})
|
assert {:ok, conversation} = AI.start_chat(%{title: "Tool Chat", model: "gpt-4.1"})
|
||||||
|
|
||||||
@@ -4164,14 +4381,14 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ ~s(class="chat-input chat-surface-input ui-textarea")
|
assert html =~ ~s(class="chat-input chat-surface-input ui-textarea")
|
||||||
|
|
||||||
css = desktop_css_source()
|
css = desktop_css_source()
|
||||||
assert css =~ "--chat-input-line-height: 20px;"
|
assert css =~ "--chat-input-line-height: 22px;"
|
||||||
assert css =~ "--chat-input-min-height: 20px;"
|
assert css =~ "--chat-input-min-height: 24px;"
|
||||||
assert css =~ ".chat-panel .chat-input-container"
|
assert css =~ ".chat-panel .chat-input-container"
|
||||||
assert css =~ "padding: 8px 16px;"
|
assert css =~ "padding: 12px 16px;"
|
||||||
assert css =~ "padding: 6px 8px;"
|
assert css =~ "padding: 6px 8px;"
|
||||||
assert css =~ ".chat-panel .chat-input-wrapper"
|
assert css =~ ".chat-panel .chat-input-wrapper"
|
||||||
assert css =~ "min-height: 30px;"
|
assert css =~ "min-height: 40px;"
|
||||||
assert css =~ "padding: 4px 6px;"
|
assert css =~ "padding: 6px 8px;"
|
||||||
assert css =~ ".chat-panel .chat-input"
|
assert css =~ ".chat-panel .chat-input"
|
||||||
assert css =~ "box-sizing: border-box;"
|
assert css =~ "box-sizing: border-box;"
|
||||||
assert css =~ "margin: 0;"
|
assert css =~ "margin: 0;"
|
||||||
@@ -4484,7 +4701,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert assistant_index < user_index
|
assert assistant_index < user_index
|
||||||
assert html =~ ~r/<textarea[^>]*class="chat-input chat-surface-input ui-textarea"[^>]*disabled/
|
assert html =~ ~r/<textarea[^>]*class="chat-input chat-surface-input ui-textarea"[^>]*disabled/
|
||||||
|
|
||||||
send(view.pid, {
|
send_and_await(view, {
|
||||||
:chat_tool_call,
|
:chat_tool_call,
|
||||||
conversation.id,
|
conversation.id,
|
||||||
%{
|
%{
|
||||||
@@ -4548,11 +4765,13 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
|> element(".chat-input-wrapper")
|
|> element(".chat-input-wrapper")
|
||||||
|> render_change(%{"message" => "Newest question"})
|
|> render_change(%{"message" => "Newest question"})
|
||||||
|
|
||||||
_html =
|
html =
|
||||||
view
|
view
|
||||||
|> element("[data-testid='chat-send-button']")
|
|> element("[data-testid='chat-send-button']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ ~s(data-testid="chat-streaming-thinking")
|
||||||
|
|
||||||
assert Enum.count(AI.list_chat_messages(conversation.id), fn message ->
|
assert Enum.count(AI.list_chat_messages(conversation.id), fn message ->
|
||||||
message.role == :user and message.content == "Newest question"
|
message.role == :user and message.content == "Newest question"
|
||||||
end) == 1
|
end) == 1
|
||||||
@@ -4579,7 +4798,9 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
send(view.pid, {
|
_ensure_sync = render(view)
|
||||||
|
|
||||||
|
send_and_await(view, {
|
||||||
:chat_tool_call,
|
:chat_tool_call,
|
||||||
conversation.id,
|
conversation.id,
|
||||||
%{
|
%{
|
||||||
@@ -4850,6 +5071,20 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
run_git!(project_dir, ["commit", "-m", message])
|
run_git!(project_dir, ["commit", "-m", message])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Sends a message to the LiveView and blocks until it has been processed.
|
||||||
|
# Uses a test ping/pong to guarantee the message was handled before returning.
|
||||||
|
defp send_and_await(view, message) do
|
||||||
|
ref = make_ref()
|
||||||
|
send(view.pid, message)
|
||||||
|
send(view.pid, {:test_ping, self(), ref})
|
||||||
|
|
||||||
|
receive do
|
||||||
|
{:test_pong, ^ref} -> :ok
|
||||||
|
after
|
||||||
|
5000 -> raise "LiveView did not process messages within 5s"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp run_git!(dir, args) do
|
defp run_git!(dir, args) do
|
||||||
{output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
|
{output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
|
||||||
|
|
||||||
|
|||||||
252
test/bds/gallery_pipeline_test.exs
Normal file
252
test/bds/gallery_pipeline_test.exs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
defmodule BDS.GalleryPipelineTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias BDS.{Repo, Media}
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
|
||||||
|
|
||||||
|
temp_dir =
|
||||||
|
Path.join(System.tmp_dir!(), "bds-gallery-test-#{System.unique_integer([:positive])}")
|
||||||
|
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "Gallery Test", data_path: temp_dir})
|
||||||
|
{:ok, _project} = BDS.Projects.set_active_project(project.id)
|
||||||
|
|
||||||
|
{:ok, _metadata} =
|
||||||
|
BDS.Metadata.update_project_metadata(project.id, %{
|
||||||
|
blog_languages: ["de", "fr"],
|
||||||
|
main_language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Gallery Post",
|
||||||
|
content: "Content"
|
||||||
|
})
|
||||||
|
|
||||||
|
%{project: project, post: post, temp_dir: temp_dir}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GalleryImport.start/6 concurrency" do
|
||||||
|
test "processes all paths with a sliding window", %{project: project, post: post} do
|
||||||
|
parent = self()
|
||||||
|
concurrency = 2
|
||||||
|
|
||||||
|
paths =
|
||||||
|
Enum.map(1..5, fn i ->
|
||||||
|
path = Path.join(project.data_path, "image_#{i}.txt")
|
||||||
|
File.write!(path, "content #{i}")
|
||||||
|
path
|
||||||
|
end)
|
||||||
|
|
||||||
|
ref = make_ref()
|
||||||
|
send(parent, {:test_gallery_start, ref, paths, project.id, post.id, concurrency})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── gallery_count with [[gallery]] ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe "gallery_count" do
|
||||||
|
test "counts [[gallery]] macro as gallery content" do
|
||||||
|
form = %{"content" => "Some text\n\n[[gallery]]\n\nMore text"}
|
||||||
|
|
||||||
|
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "counts inline images as before" do
|
||||||
|
form = %{"content" => ""}
|
||||||
|
|
||||||
|
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "counts both [[gallery]] and inline images" do
|
||||||
|
form = %{"content" => "\n[[gallery]]"}
|
||||||
|
|
||||||
|
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 0 when no gallery marks present" do
|
||||||
|
form = %{"content" => "Just plain text"}
|
||||||
|
|
||||||
|
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles empty content" do
|
||||||
|
form = %{}
|
||||||
|
|
||||||
|
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "counts multiple [[gallery]] occurrences" do
|
||||||
|
form = %{"content" => "[[gallery]] some text [[gallery]]"}
|
||||||
|
|
||||||
|
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "is case insensitive for [[gallery]]" do
|
||||||
|
form = %{"content" => "[[Gallery]]"}
|
||||||
|
|
||||||
|
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Rendering macro smoke tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe "rendering macros" do
|
||||||
|
test "[[gallery]] renders without linked media", %{project: project, post: post} do
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
language = metadata.main_language || "en"
|
||||||
|
|
||||||
|
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
|
||||||
|
|
||||||
|
result =
|
||||||
|
BDS.Rendering.Filters.render_markdown(
|
||||||
|
"[[gallery]]",
|
||||||
|
%{},
|
||||||
|
%{},
|
||||||
|
language,
|
||||||
|
template_context,
|
||||||
|
post.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result =~ "gallery-empty"
|
||||||
|
assert result =~ "macro-gallery"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "[[gallery]] renders linked media when present", %{project: project, post: post} do
|
||||||
|
source = Path.join(project.data_path, "gallery_test.jpg")
|
||||||
|
File.write!(source, "fake jpeg")
|
||||||
|
|
||||||
|
{:ok, media} =
|
||||||
|
Media.import_media(%{project_id: project.id, source_path: source})
|
||||||
|
|
||||||
|
{:ok, _updated} =
|
||||||
|
Media.update_media(media.id, %{
|
||||||
|
title: "Test Image",
|
||||||
|
alt: "Alt text",
|
||||||
|
caption: "Caption"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _link} = Media.link_media_to_post(media.id, post.id)
|
||||||
|
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
language = metadata.main_language || "en"
|
||||||
|
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
|
||||||
|
|
||||||
|
result =
|
||||||
|
BDS.Rendering.Filters.render_markdown(
|
||||||
|
"[[gallery]]",
|
||||||
|
%{},
|
||||||
|
%{},
|
||||||
|
language,
|
||||||
|
template_context,
|
||||||
|
post.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result =~ "macro-gallery"
|
||||||
|
assert result =~ "gallery-item"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "[[tag_cloud]] renders without tags", %{project: project, post: post} do
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
language = metadata.main_language || "en"
|
||||||
|
|
||||||
|
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
|
||||||
|
|
||||||
|
result =
|
||||||
|
BDS.Rendering.Filters.render_markdown(
|
||||||
|
"[[tag_cloud]]",
|
||||||
|
%{},
|
||||||
|
%{},
|
||||||
|
language,
|
||||||
|
template_context,
|
||||||
|
post.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result =~ "tag-cloud-empty"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "[[photo_archive]] renders without media", %{project: project, post: post} do
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
language = metadata.main_language || "en"
|
||||||
|
|
||||||
|
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
|
||||||
|
|
||||||
|
result =
|
||||||
|
BDS.Rendering.Filters.render_markdown(
|
||||||
|
"[[photo_archive]]",
|
||||||
|
%{},
|
||||||
|
%{},
|
||||||
|
language,
|
||||||
|
template_context,
|
||||||
|
post.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result =~ "photo-archive-empty"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "[[photo_archive year=2024]] renders with date filter", %{project: project, post: post} do
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
language = metadata.main_language || "en"
|
||||||
|
|
||||||
|
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
|
||||||
|
|
||||||
|
result =
|
||||||
|
BDS.Rendering.Filters.render_markdown(
|
||||||
|
"[[photo_archive year=2024]]",
|
||||||
|
%{},
|
||||||
|
%{},
|
||||||
|
language,
|
||||||
|
template_context,
|
||||||
|
post.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result =~ "photo-archive"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "[[youtube id=abc123]] still works", %{project: project, post: post} do
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
language = metadata.main_language || "en"
|
||||||
|
|
||||||
|
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
|
||||||
|
|
||||||
|
result =
|
||||||
|
BDS.Rendering.Filters.render_markdown(
|
||||||
|
"[[youtube id=abc123]]",
|
||||||
|
%{},
|
||||||
|
%{},
|
||||||
|
language,
|
||||||
|
template_context,
|
||||||
|
post.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result =~ "youtube"
|
||||||
|
assert result =~ "abc123"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "[[vimeo id=456789]] still works", %{project: project, post: post} do
|
||||||
|
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||||
|
language = metadata.main_language || "en"
|
||||||
|
|
||||||
|
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
|
||||||
|
|
||||||
|
result =
|
||||||
|
BDS.Rendering.Filters.render_markdown(
|
||||||
|
"[[vimeo id=456789]]",
|
||||||
|
%{},
|
||||||
|
%{},
|
||||||
|
language,
|
||||||
|
template_context,
|
||||||
|
post.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result =~ "vimeo"
|
||||||
|
assert result =~ "456789"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
216
test/bds/image_import_pipeline_test.exs
Normal file
216
test/bds/image_import_pipeline_test.exs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
defmodule BDS.ImageImportPipelineTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
import Phoenix.ConnTest
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias BDS.Desktop.{FilePicker, Overlay}
|
||||||
|
alias BDS.{Metadata, AI, Repo}
|
||||||
|
|
||||||
|
@endpoint BDS.Desktop.Endpoint
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
|
||||||
|
|
||||||
|
temp_dir =
|
||||||
|
Path.join(System.tmp_dir!(), "bds-image-import-#{System.unique_integer([:positive])}")
|
||||||
|
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "Image Import Test", data_path: temp_dir})
|
||||||
|
%{project: project, temp_dir: temp_dir}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── FilePicker multi-select parsing ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe "FilePicker.choose_files/2" do
|
||||||
|
test "single selection returns a single-item list" do
|
||||||
|
# Simulate what osascript returns for regular choose file
|
||||||
|
result = FilePicker.parse_choose_files_result("/Users/test/image.png", false)
|
||||||
|
assert result == {:ok, "/Users/test/image.png"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "multi selection parses newline-separated paths" do
|
||||||
|
result =
|
||||||
|
FilePicker.parse_choose_files_result(
|
||||||
|
"/Users/test/photo1.jpg\n/Users/test/photo2.png\n/Users/test/photo3.heic",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result ==
|
||||||
|
{:ok, ["/Users/test/photo1.jpg", "/Users/test/photo2.png", "/Users/test/photo3.heic"]}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "multi selection filters out empty lines" do
|
||||||
|
result =
|
||||||
|
FilePicker.parse_choose_files_result(
|
||||||
|
"/Users/test/photo1.jpg\n\n/Users/test/photo2.png\n \n/Users/test/photo3.heic\n",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result ==
|
||||||
|
{:ok, ["/Users/test/photo1.jpg", "/Users/test/photo2.png", "/Users/test/photo3.heic"]}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "multi selection with single file returns list with one element" do
|
||||||
|
result = FilePicker.parse_choose_files_result("/Users/test/photo1.jpg", true)
|
||||||
|
assert result == {:ok, ["/Users/test/photo1.jpg"]}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Metadata image_import_concurrency ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe "Metadata image_import_concurrency" do
|
||||||
|
test "defaults to 4 for new projects", %{project: project} do
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.image_import_concurrency == 4
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clamps to minimum 1", %{project: project} do
|
||||||
|
{:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{image_import_concurrency: 0})
|
||||||
|
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.image_import_concurrency == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clamps to maximum 8", %{project: project} do
|
||||||
|
{:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{image_import_concurrency: 100})
|
||||||
|
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.image_import_concurrency == 8
|
||||||
|
end
|
||||||
|
|
||||||
|
test "persists and reads correctly", %{project: project} do
|
||||||
|
{:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{image_import_concurrency: 3})
|
||||||
|
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.image_import_concurrency == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles string input", %{project: project} do
|
||||||
|
{:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{image_import_concurrency: "5"})
|
||||||
|
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.image_import_concurrency == 5
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles nil as default", %{project: project} do
|
||||||
|
{:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{image_import_concurrency: nil})
|
||||||
|
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
||||||
|
assert metadata.image_import_concurrency == 4
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reflects in form as string", %{project: project} do
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
||||||
|
|
||||||
|
form =
|
||||||
|
BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings.project_form(metadata)
|
||||||
|
|
||||||
|
assert form["image_import_concurrency"] == "4"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Overlay struct post_id ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe "Overlay insert_media" do
|
||||||
|
test "open(:post, :insert_media, context) includes post_id" do
|
||||||
|
context = %{
|
||||||
|
current_tab: %{
|
||||||
|
type: :post,
|
||||||
|
id: "post-uuid-123",
|
||||||
|
title: "Test Post",
|
||||||
|
subtitle: "draft"
|
||||||
|
},
|
||||||
|
media: [],
|
||||||
|
insert_media_title: "Insert Media"
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay = Overlay.open(:post, :insert_media, context)
|
||||||
|
assert overlay.post_id == "post-uuid-123"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "post_id is nil when opened from non-post context" do
|
||||||
|
context = %{
|
||||||
|
current_tab: %{
|
||||||
|
type: :media,
|
||||||
|
id: "media-uuid-456",
|
||||||
|
title: "Test Media",
|
||||||
|
subtitle: "image/png"
|
||||||
|
},
|
||||||
|
media: [],
|
||||||
|
insert_media_title: "Insert Media"
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay = Overlay.open(:media, :insert_media, context)
|
||||||
|
assert overlay == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "set_search_query preserves post_id" do
|
||||||
|
overlay = %{
|
||||||
|
kind: :insert_media,
|
||||||
|
title: "Insert Media",
|
||||||
|
search_query: "",
|
||||||
|
results: [],
|
||||||
|
all_media: [],
|
||||||
|
post_id: "post-uuid-789"
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Overlay.set_search_query(overlay, "search term")
|
||||||
|
assert result.post_id == "post-uuid-789"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Airplane mode gating via shell_live event ─────────────────────────────
|
||||||
|
|
||||||
|
describe "overlay_add_images airplane mode gating" do
|
||||||
|
setup do
|
||||||
|
prev_auto = System.get_env("BDS_DESKTOP_AUTOMATION")
|
||||||
|
System.put_env("BDS_DESKTOP_AUTOMATION", "1")
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
if prev_auto,
|
||||||
|
do: System.put_env("BDS_DESKTOP_AUTOMATION", prev_auto),
|
||||||
|
else: System.delete_env("BDS_DESKTOP_AUTOMATION")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows toast in airplane mode when Add Gallery Images is clicked", %{project: project} do
|
||||||
|
{:ok, _project} = BDS.Projects.set_active_project(project.id)
|
||||||
|
|
||||||
|
assert :ok = AI.set_airplane_mode(true)
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Gallery Post",
|
||||||
|
content: "Content"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => "draft"
|
||||||
|
})
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[phx-click='add_gallery_images']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "Automatic AI actions stay gated by airplane mode"
|
||||||
|
|
||||||
|
assert :ok = AI.set_airplane_mode(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -188,6 +188,59 @@ defmodule BDS.PostsTest do
|
|||||||
refute File.exists?(full_path <> ".tmp")
|
refute File.exists?(full_path <> ".tmp")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "publish_post omits doNotTranslate from frontmatter when false", %{
|
||||||
|
project: project,
|
||||||
|
temp_dir: temp_dir
|
||||||
|
} do
|
||||||
|
assert {:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Normal Post",
|
||||||
|
content: "body"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert post.do_not_translate == false
|
||||||
|
assert {:ok, published} = BDS.Posts.publish_post(post.id)
|
||||||
|
|
||||||
|
full_path = Path.join(temp_dir, published.file_path)
|
||||||
|
file_contents = File.read!(full_path)
|
||||||
|
|
||||||
|
refute file_contents =~ "doNotTranslate"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "publish_post deletes old file when file path changes", %{
|
||||||
|
project: project,
|
||||||
|
temp_dir: temp_dir
|
||||||
|
} do
|
||||||
|
assert {:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Moving Post",
|
||||||
|
content: "Body"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, published} = BDS.Posts.publish_post(post.id)
|
||||||
|
new_path = Path.join(temp_dir, published.file_path)
|
||||||
|
assert File.exists?(new_path)
|
||||||
|
|
||||||
|
old_relative = "posts/1999/01/moving-post.md"
|
||||||
|
old_full = Path.join(temp_dir, old_relative)
|
||||||
|
File.mkdir_p!(Path.dirname(old_full))
|
||||||
|
File.write!(old_full, "stale content")
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
BDS.Repo.update_all(
|
||||||
|
from(p in BDS.Posts.Post, where: p.id == ^published.id),
|
||||||
|
set: [file_path: old_relative, content: "edited body"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:ok, republished} = BDS.Posts.publish_post(published.id)
|
||||||
|
assert republished.file_path == published.file_path
|
||||||
|
|
||||||
|
assert File.exists?(Path.join(temp_dir, republished.file_path))
|
||||||
|
refute File.exists?(old_full)
|
||||||
|
end
|
||||||
|
|
||||||
test "delete_post removes the database row and published markdown file when present" do
|
test "delete_post removes the database row and published markdown file when present" do
|
||||||
temp_dir =
|
temp_dir =
|
||||||
Path.join(System.tmp_dir!(), "bds-post-delete-#{System.unique_integer([:positive])}")
|
Path.join(System.tmp_dir!(), "bds-post-delete-#{System.unique_integer([:positive])}")
|
||||||
@@ -228,6 +281,43 @@ defmodule BDS.PostsTest do
|
|||||||
assert {:error, :not_found} = BDS.Posts.delete_post(post.id)
|
assert {:error, :not_found} = BDS.Posts.delete_post(post.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "delete_post deletes translation records and their files from disk" do
|
||||||
|
temp_dir =
|
||||||
|
Path.join(System.tmp_dir!(), "bds-post-del-trans-#{System.unique_integer([:positive])}")
|
||||||
|
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
assert {:ok, project} =
|
||||||
|
BDS.Projects.create_project(%{name: "DelTrans", data_path: temp_dir})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Parent Post",
|
||||||
|
content: "Body"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, translation} =
|
||||||
|
BDS.Posts.upsert_post_translation(post.id, "de", %{
|
||||||
|
title: "Elternbeitrag",
|
||||||
|
content: "Inhalt"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _published} = BDS.Posts.publish_post(post.id)
|
||||||
|
|
||||||
|
published_translation = BDS.Posts.get_post_translation!(translation.id)
|
||||||
|
translation_path = Path.join(temp_dir, published_translation.file_path)
|
||||||
|
assert File.exists?(translation_path)
|
||||||
|
|
||||||
|
assert {:ok, :deleted} = BDS.Posts.delete_post(post.id)
|
||||||
|
|
||||||
|
assert BDS.Repo.get(BDS.Posts.Post, post.id) == nil
|
||||||
|
assert {:ok, []} = BDS.Posts.list_post_translations(post.id)
|
||||||
|
|
||||||
|
refute File.exists?(translation_path)
|
||||||
|
end
|
||||||
|
|
||||||
test "delete_post returns not_found for nonexistent post" do
|
test "delete_post returns not_found for nonexistent post" do
|
||||||
assert {:error, :not_found} = BDS.Posts.delete_post(Ecto.UUID.generate())
|
assert {:error, :not_found} = BDS.Posts.delete_post(Ecto.UUID.generate())
|
||||||
end
|
end
|
||||||
@@ -296,6 +386,65 @@ defmodule BDS.PostsTest do
|
|||||||
assert contents =~ "\n---\nBody\n"
|
assert contents =~ "\n---\nBody\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "unarchive_post transitions archived draft back to draft", %{project: project} do
|
||||||
|
assert {:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Archive Then Unarchive",
|
||||||
|
content: "Draft body"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, archived} = BDS.Posts.archive_post(post.id)
|
||||||
|
assert archived.status == :archived
|
||||||
|
|
||||||
|
assert {:ok, unarchived} = BDS.Posts.unarchive_post(archived.id)
|
||||||
|
assert unarchived.status == :draft
|
||||||
|
assert unarchived.content == "Draft body"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unarchive_post restores content from disk for previously published posts" do
|
||||||
|
temp_dir =
|
||||||
|
Path.join(System.tmp_dir!(), "bds-post-unarchive-#{System.unique_integer([:positive])}")
|
||||||
|
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
assert {:ok, project} =
|
||||||
|
BDS.Projects.create_project(%{name: "Unarchive Pub", data_path: temp_dir})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Publish Then Archive",
|
||||||
|
content: "Published body"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, published} = BDS.Posts.publish_post(post.id)
|
||||||
|
assert published.content == nil
|
||||||
|
assert {:ok, archived} = BDS.Posts.archive_post(published.id)
|
||||||
|
assert archived.status == :archived
|
||||||
|
assert archived.content == nil
|
||||||
|
|
||||||
|
assert {:ok, unarchived} = BDS.Posts.unarchive_post(archived.id)
|
||||||
|
assert unarchived.status == :draft
|
||||||
|
assert unarchived.content == "Published body"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unarchive_post rejects non-archived posts", %{project: project} do
|
||||||
|
assert {:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Still Draft",
|
||||||
|
content: "Body"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:error, %Ecto.Changeset{}} = BDS.Posts.unarchive_post(post.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unarchive_post returns not_found for nonexistent post" do
|
||||||
|
assert {:error, :not_found} = BDS.Posts.unarchive_post(Ecto.UUID.generate())
|
||||||
|
end
|
||||||
|
|
||||||
test "rebuild_posts_from_files recreates published posts from disk" do
|
test "rebuild_posts_from_files recreates published posts from disk" do
|
||||||
temp_dir =
|
temp_dir =
|
||||||
Path.join(System.tmp_dir!(), "bds-post-rebuild-#{System.unique_integer([:positive])}")
|
Path.join(System.tmp_dir!(), "bds-post-rebuild-#{System.unique_integer([:positive])}")
|
||||||
|
|||||||
@@ -66,15 +66,21 @@ defmodule BDS.PreviewTest do
|
|||||||
assert server.port == 4123
|
assert server.port == 4123
|
||||||
assert server.is_running == true
|
assert server.is_running == true
|
||||||
|
|
||||||
assert {:ok, %{body: "<html>home</html>", content_type: "text/html"}} =
|
assert {:ok, %{body: home_html, content_type: "text/html"}} =
|
||||||
BDS.Preview.request(project.id, "/")
|
BDS.Preview.request(project.id, "/")
|
||||||
|
|
||||||
assert {:ok, %{body: "<html>startseite</html>", content_type: "text/html"}} =
|
assert home_html =~ "<html"
|
||||||
|
|
||||||
|
assert {:ok, %{body: de_html, content_type: "text/html"}} =
|
||||||
BDS.Preview.request(project.id, "/de/")
|
BDS.Preview.request(project.id, "/de/")
|
||||||
|
|
||||||
assert {:ok, %{body: "<html>tag archive</html>", content_type: "text/html"}} =
|
assert de_html =~ "<html"
|
||||||
|
|
||||||
|
assert {:ok, %{body: tag_html, content_type: "text/html"}} =
|
||||||
BDS.Preview.request(project.id, "/tag/elixir")
|
BDS.Preview.request(project.id, "/tag/elixir")
|
||||||
|
|
||||||
|
assert tag_html =~ "<html"
|
||||||
|
|
||||||
assert {:ok, %{body: "console.log('pagefind')", content_type: "application/javascript"}} =
|
assert {:ok, %{body: "console.log('pagefind')", content_type: "application/javascript"}} =
|
||||||
BDS.Preview.request(project.id, "/pagefind/pagefind-ui.js")
|
BDS.Preview.request(project.id, "/pagefind/pagefind-ui.js")
|
||||||
|
|
||||||
@@ -381,9 +387,6 @@ defmodule BDS.PreviewTest do
|
|||||||
blog_languages: ["en"]
|
blog_languages: ["en"]
|
||||||
})
|
})
|
||||||
|
|
||||||
assert {:ok, _} =
|
|
||||||
Generation.write_generated_file(project.id, "index.html", "<html>http home</html>")
|
|
||||||
|
|
||||||
assert {:ok, post} =
|
assert {:ok, post} =
|
||||||
Posts.create_post(%{
|
Posts.create_post(%{
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
@@ -399,7 +402,7 @@ defmodule BDS.PreviewTest do
|
|||||||
body_format: :binary
|
body_format: :binary
|
||||||
)
|
)
|
||||||
|
|
||||||
assert body == "<html>http home</html>"
|
assert body =~ "<html"
|
||||||
|
|
||||||
assert Enum.any?(headers, fn {name, value} ->
|
assert Enum.any?(headers, fn {name, value} ->
|
||||||
String.downcase(to_string(name)) == "content-type" and
|
String.downcase(to_string(name)) == "content-type" and
|
||||||
@@ -421,6 +424,288 @@ defmodule BDS.PreviewTest do
|
|||||||
assert :ok = BDS.Preview.stop_preview(project.id)
|
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "on-demand rendering: published post route renders via template without generated files", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
main_language: "en",
|
||||||
|
blog_languages: ["en"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "On-Demand Post",
|
||||||
|
content: "**Rendered** on demand",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, published} = Posts.publish_post(post.id)
|
||||||
|
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||||
|
|
||||||
|
datetime = DateTime.from_unix!(published.created_at, :millisecond)
|
||||||
|
y = Integer.to_string(datetime.year)
|
||||||
|
m = String.pad_leading(Integer.to_string(datetime.month), 2, "0")
|
||||||
|
d = String.pad_leading(Integer.to_string(datetime.day), 2, "0")
|
||||||
|
|
||||||
|
assert {:ok, %{body: html, content_type: "text/html"}} =
|
||||||
|
BDS.Preview.request(project.id, "/#{y}/#{m}/#{d}/#{published.slug}")
|
||||||
|
|
||||||
|
assert html =~ "On-Demand Post"
|
||||||
|
assert html =~ "<strong>Rendered</strong> on demand"
|
||||||
|
|
||||||
|
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "on-demand rendering: home page renders published posts as list without generated files", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
main_language: "en",
|
||||||
|
blog_languages: ["en"],
|
||||||
|
max_posts_per_page: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Home Listed Post",
|
||||||
|
content: "Listed body",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _published} = Posts.publish_post(post.id)
|
||||||
|
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||||
|
|
||||||
|
assert {:ok, %{body: html, content_type: "text/html"}} =
|
||||||
|
BDS.Preview.request(project.id, "/")
|
||||||
|
|
||||||
|
assert html =~ "Home Listed Post"
|
||||||
|
|
||||||
|
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "on-demand rendering: category archive renders filtered posts", %{project: project} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
main_language: "en",
|
||||||
|
blog_languages: ["en"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Article Post",
|
||||||
|
content: "Article body",
|
||||||
|
language: "en",
|
||||||
|
categories: ["article"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _published} = Posts.publish_post(post.id)
|
||||||
|
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||||
|
|
||||||
|
assert {:ok, %{body: html, content_type: "text/html"}} =
|
||||||
|
BDS.Preview.request(project.id, "/category/article")
|
||||||
|
|
||||||
|
assert html =~ "Article Post"
|
||||||
|
|
||||||
|
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "on-demand rendering: tag archive renders filtered posts", %{project: project} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
main_language: "en",
|
||||||
|
blog_languages: ["en"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Tagged Post",
|
||||||
|
content: "Tagged body",
|
||||||
|
language: "en",
|
||||||
|
tags: ["elixir"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _published} = Posts.publish_post(post.id)
|
||||||
|
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||||
|
|
||||||
|
assert {:ok, %{body: html, content_type: "text/html"}} =
|
||||||
|
BDS.Preview.request(project.id, "/tag/elixir")
|
||||||
|
|
||||||
|
assert html =~ "Tagged Post"
|
||||||
|
|
||||||
|
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "on-demand rendering: language-prefixed route renders with translation overlay", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
main_language: "en",
|
||||||
|
blog_languages: ["en", "de"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "English Post",
|
||||||
|
content: "English body",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _published} = Posts.publish_post(post.id)
|
||||||
|
|
||||||
|
assert {:ok, _translation} =
|
||||||
|
Posts.upsert_post_translation(post.id, "de", %{
|
||||||
|
title: "Deutscher Beitrag",
|
||||||
|
content: "Deutscher Inhalt"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _pub_translation} = Posts.publish_post_translation(post.id, "de")
|
||||||
|
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||||
|
|
||||||
|
assert {:ok, %{body: html, content_type: "text/html"}} =
|
||||||
|
BDS.Preview.request(project.id, "/de/")
|
||||||
|
|
||||||
|
assert html =~ "Deutscher Beitrag"
|
||||||
|
|
||||||
|
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "on-demand rendering: year archive renders date-filtered posts", %{project: project} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
main_language: "en",
|
||||||
|
blog_languages: ["en"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Dated Post",
|
||||||
|
content: "Dated body",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, published} = Posts.publish_post(post.id)
|
||||||
|
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||||
|
|
||||||
|
datetime = DateTime.from_unix!(published.created_at, :millisecond)
|
||||||
|
|
||||||
|
assert {:ok, %{body: html, content_type: "text/html"}} =
|
||||||
|
BDS.Preview.request(project.id, "/#{datetime.year}")
|
||||||
|
|
||||||
|
assert html =~ "Dated Post"
|
||||||
|
|
||||||
|
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "on-demand rendering: draft post (never published) is visible and uses DB content", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
main_language: "en",
|
||||||
|
blog_languages: ["en"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Pure Draft",
|
||||||
|
content: "Draft-only content",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert post.status == :draft
|
||||||
|
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||||
|
|
||||||
|
datetime = DateTime.from_unix!(post.created_at, :millisecond)
|
||||||
|
y = Integer.to_string(datetime.year)
|
||||||
|
m = String.pad_leading(Integer.to_string(datetime.month), 2, "0")
|
||||||
|
d = String.pad_leading(Integer.to_string(datetime.day), 2, "0")
|
||||||
|
|
||||||
|
assert {:ok, %{body: html, content_type: "text/html"}} =
|
||||||
|
BDS.Preview.request(project.id, "/#{y}/#{m}/#{d}/#{post.slug}")
|
||||||
|
|
||||||
|
assert html =~ "Pure Draft"
|
||||||
|
assert html =~ "Draft-only content"
|
||||||
|
|
||||||
|
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "on-demand rendering: published-then-edited post shows draft DB content over file", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
main_language: "en",
|
||||||
|
blog_languages: ["en"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Published Then Edited",
|
||||||
|
content: "Original content",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, published} = Posts.publish_post(post.id)
|
||||||
|
|
||||||
|
{:ok, edited} =
|
||||||
|
Posts.update_post(published.id, %{
|
||||||
|
title: "Edited Title",
|
||||||
|
content: "Edited draft content"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||||
|
|
||||||
|
datetime = DateTime.from_unix!(edited.created_at, :millisecond)
|
||||||
|
y = Integer.to_string(datetime.year)
|
||||||
|
m = String.pad_leading(Integer.to_string(datetime.month), 2, "0")
|
||||||
|
d = String.pad_leading(Integer.to_string(datetime.day), 2, "0")
|
||||||
|
|
||||||
|
assert {:ok, %{body: html, content_type: "text/html"}} =
|
||||||
|
BDS.Preview.request(project.id, "/#{y}/#{m}/#{d}/#{edited.slug}")
|
||||||
|
|
||||||
|
assert html =~ "Edited Title"
|
||||||
|
assert html =~ "Edited draft content"
|
||||||
|
refute html =~ "Original content"
|
||||||
|
|
||||||
|
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "on-demand rendering: draft posts appear in home listing", %{project: project} do
|
||||||
|
assert {:ok, _metadata} =
|
||||||
|
Metadata.update_project_metadata(project.id, %{
|
||||||
|
main_language: "en",
|
||||||
|
blog_languages: ["en"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _draft} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Draft In List",
|
||||||
|
content: "Draft list body",
|
||||||
|
language: "en"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
|
||||||
|
|
||||||
|
assert {:ok, %{body: html, content_type: "text/html"}} =
|
||||||
|
BDS.Preview.request(project.id, "/")
|
||||||
|
|
||||||
|
assert html =~ "Draft In List"
|
||||||
|
|
||||||
|
assert :ok = BDS.Preview.stop_preview(project.id)
|
||||||
|
end
|
||||||
|
|
||||||
test "preview query params can override the rendered theme for generated and draft pages", %{
|
test "preview query params can override the rendered theme for generated and draft pages", %{
|
||||||
project: project
|
project: project
|
||||||
} do
|
} do
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ defmodule BDS.Repo.SchemaMigrationTest do
|
|||||||
"title",
|
"title",
|
||||||
"model",
|
"model",
|
||||||
"copilot_session_id",
|
"copilot_session_id",
|
||||||
|
"surface_state",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at"
|
"updated_at"
|
||||||
],
|
],
|
||||||
|
|||||||
215
test/bds/template_lookup_priority_test.exs
Normal file
215
test/bds/template_lookup_priority_test.exs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
defmodule BDS.TemplateLookupPriorityTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias BDS.Rendering.TemplateSelection
|
||||||
|
|
||||||
|
setup do
|
||||||
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||||
|
temp_dir = Path.join(System.tmp_dir!(), "bds-tlp-#{System.unique_integer([:positive])}")
|
||||||
|
File.mkdir_p!(temp_dir)
|
||||||
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||||
|
|
||||||
|
{:ok, project} = BDS.Projects.create_project(%{name: "TLP Test", data_path: temp_dir})
|
||||||
|
|
||||||
|
{:ok, _metadata} =
|
||||||
|
BDS.Metadata.update_project_metadata(project.id, %{
|
||||||
|
main_language: "en",
|
||||||
|
blog_languages: ["en"]
|
||||||
|
})
|
||||||
|
|
||||||
|
%{project: project}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_published_template(project_id, slug, content) do
|
||||||
|
{:ok, template} =
|
||||||
|
BDS.Templates.create_template(%{
|
||||||
|
project_id: project_id,
|
||||||
|
title: "Template #{slug}",
|
||||||
|
kind: :post,
|
||||||
|
content: content
|
||||||
|
})
|
||||||
|
|
||||||
|
if template.slug != slug do
|
||||||
|
BDS.Repo.update!(Ecto.Changeset.change(template, slug: slug))
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, published} = BDS.Templates.publish_template(template.id)
|
||||||
|
published
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_tag(project_id, name, post_template_slug) do
|
||||||
|
{:ok, tag} =
|
||||||
|
BDS.Tags.create_tag(%{
|
||||||
|
project_id: project_id,
|
||||||
|
name: name,
|
||||||
|
post_template_slug: post_template_slug
|
||||||
|
})
|
||||||
|
|
||||||
|
tag
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "resolve_post_template_slug/3" do
|
||||||
|
test "level 1: returns post's own template_slug when set", %{project: project} do
|
||||||
|
result =
|
||||||
|
TemplateSelection.resolve_post_template_slug(
|
||||||
|
project.id,
|
||||||
|
["some-tag"],
|
||||||
|
["article"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "level 2: falls back to tag's post_template_slug", %{project: project} do
|
||||||
|
_tag = create_tag(project.id, "photo", "photo-layout")
|
||||||
|
|
||||||
|
result =
|
||||||
|
TemplateSelection.resolve_post_template_slug(
|
||||||
|
project.id,
|
||||||
|
["photo"],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "photo-layout"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "level 2: uses first matching tag with a template slug", %{project: project} do
|
||||||
|
_tag1 = create_tag(project.id, "news", nil)
|
||||||
|
_tag2 = create_tag(project.id, "gallery", "gallery-layout")
|
||||||
|
|
||||||
|
result =
|
||||||
|
TemplateSelection.resolve_post_template_slug(
|
||||||
|
project.id,
|
||||||
|
["news", "gallery"],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "gallery-layout"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "level 3: falls back to category's post_template_slug", %{project: project} do
|
||||||
|
{:ok, _} = BDS.Metadata.add_category(project.id, "review")
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
BDS.Metadata.update_category_settings(project.id, "review", %{
|
||||||
|
"post_template_slug" => "review-layout"
|
||||||
|
})
|
||||||
|
|
||||||
|
result =
|
||||||
|
TemplateSelection.resolve_post_template_slug(
|
||||||
|
project.id,
|
||||||
|
[],
|
||||||
|
["review"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "review-layout"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "level 2 takes priority over level 3", %{project: project} do
|
||||||
|
_tag = create_tag(project.id, "featured", "featured-layout")
|
||||||
|
{:ok, _} = BDS.Metadata.add_category(project.id, "review")
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
BDS.Metadata.update_category_settings(project.id, "review", %{
|
||||||
|
"post_template_slug" => "review-layout"
|
||||||
|
})
|
||||||
|
|
||||||
|
result =
|
||||||
|
TemplateSelection.resolve_post_template_slug(
|
||||||
|
project.id,
|
||||||
|
["featured"],
|
||||||
|
["review"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "featured-layout"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "level 4: returns nil when no tag or category has a template", %{project: project} do
|
||||||
|
_tag = create_tag(project.id, "plain", nil)
|
||||||
|
|
||||||
|
result =
|
||||||
|
TemplateSelection.resolve_post_template_slug(
|
||||||
|
project.id,
|
||||||
|
["plain"],
|
||||||
|
["article"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "end-to-end template lookup with rendering" do
|
||||||
|
test "post renders with tag-specific template when no post template set", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
template =
|
||||||
|
create_published_template(project.id, "photo-layout", "<h1>PHOTO: {{ page.title }}</h1>")
|
||||||
|
|
||||||
|
_tag = create_tag(project.id, "photo", template.slug)
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "My Photo Post",
|
||||||
|
content: "photo content",
|
||||||
|
language: "en",
|
||||||
|
tags: ["photo"]
|
||||||
|
})
|
||||||
|
|
||||||
|
resolved_slug =
|
||||||
|
TemplateSelection.resolve_post_template_slug(
|
||||||
|
project.id,
|
||||||
|
post.tags,
|
||||||
|
post.categories
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolved_slug == "photo-layout"
|
||||||
|
|
||||||
|
{:ok, rendered} =
|
||||||
|
TemplateSelection.load_template_source(project.id, :post, resolved_slug)
|
||||||
|
|
||||||
|
assert rendered =~ "PHOTO:"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "post renders with category-specific template when no post or tag template", %{
|
||||||
|
project: project
|
||||||
|
} do
|
||||||
|
template =
|
||||||
|
create_published_template(
|
||||||
|
project.id,
|
||||||
|
"review-layout",
|
||||||
|
"<h1>REVIEW: {{ page.title }}</h1>"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, _} = BDS.Metadata.add_category(project.id, "review")
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
BDS.Metadata.update_category_settings(project.id, "review", %{
|
||||||
|
"post_template_slug" => template.slug
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "My Review",
|
||||||
|
content: "review content",
|
||||||
|
language: "en",
|
||||||
|
categories: ["review"]
|
||||||
|
})
|
||||||
|
|
||||||
|
resolved_slug =
|
||||||
|
TemplateSelection.resolve_post_template_slug(
|
||||||
|
project.id,
|
||||||
|
post.tags,
|
||||||
|
post.categories
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolved_slug == "review-layout"
|
||||||
|
|
||||||
|
{:ok, rendered} =
|
||||||
|
TemplateSelection.load_template_source(project.id, :post, resolved_slug)
|
||||||
|
|
||||||
|
assert rendered =~ "REVIEW:"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
86
test/bds/translation_completeness_test.exs
Normal file
86
test/bds/translation_completeness_test.exs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
defmodule BDS.TranslationCompletenessTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
@translated_locales ~w(de fr it es)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Counts empty msgstr entries per non-English locale .po file and ensures
|
||||||
|
the count never increases. When a new msgid is added to the codebase its
|
||||||
|
translations MUST be provided for all supported locales; an empty msgstr
|
||||||
|
in de/fr/it/es causes this test to fail.
|
||||||
|
|
||||||
|
English files are excluded — gettext treats empty msgstr as "return msgid
|
||||||
|
unchanged" for the source language, which is the intended fallback.
|
||||||
|
|
||||||
|
The expected counts below represent current untranslated legacy entries
|
||||||
|
that must decrease over time, never increase. When the count decreases
|
||||||
|
because translations were added, update the expected count in this test.
|
||||||
|
"""
|
||||||
|
test "empty msgstr counts do not increase in non-English locale .po files" do
|
||||||
|
expected = %{
|
||||||
|
"de/default.po" => 0,
|
||||||
|
"de/render.po" => 0,
|
||||||
|
"de/ui.po" => 0,
|
||||||
|
"fr/default.po" => 0,
|
||||||
|
"fr/render.po" => 0,
|
||||||
|
"fr/ui.po" => 0,
|
||||||
|
"it/default.po" => 0,
|
||||||
|
"it/render.po" => 0,
|
||||||
|
"it/ui.po" => 0,
|
||||||
|
"es/default.po" => 0,
|
||||||
|
"es/render.po" => 0,
|
||||||
|
"es/ui.po" => 0
|
||||||
|
}
|
||||||
|
|
||||||
|
actual =
|
||||||
|
for locale <- @translated_locales,
|
||||||
|
domain <- ~w(ui default render) do
|
||||||
|
file = "priv/gettext/#{locale}/LC_MESSAGES/#{domain}.po"
|
||||||
|
|
||||||
|
if File.exists?(file) do
|
||||||
|
count = empty_msgstr_count(file)
|
||||||
|
key = "#{locale}/#{domain}.po"
|
||||||
|
{key, count}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
for {file, expected_count} <- expected do
|
||||||
|
actual_count = Map.get(actual, file, 0)
|
||||||
|
|
||||||
|
assert actual_count <= expected_count,
|
||||||
|
"#{file}: empty msgstr count increased from #{expected_count} to #{actual_count}. " <>
|
||||||
|
"All new msgid entries MUST have translations for every supported locale (de, fr, it, es). " <>
|
||||||
|
"When the count decreases because translations were added, update the expected count in this test."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp empty_msgstr_count(file) do
|
||||||
|
file
|
||||||
|
|> File.read!()
|
||||||
|
|> String.split("\n")
|
||||||
|
|> Enum.reduce({nil, 0}, fn line, {current_msgid, count} ->
|
||||||
|
trimmed = String.trim(line)
|
||||||
|
|
||||||
|
case {trimmed, current_msgid} do
|
||||||
|
{"msgstr \"\"", nil} ->
|
||||||
|
{nil, count}
|
||||||
|
|
||||||
|
{"msgstr \"\"", msgid} ->
|
||||||
|
if msgid == "" do
|
||||||
|
{nil, count}
|
||||||
|
else
|
||||||
|
{nil, count + 1}
|
||||||
|
end
|
||||||
|
|
||||||
|
{"msgid \"" <> rest, _} ->
|
||||||
|
{String.trim_trailing(rest, "\""), count}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{current_msgid, count}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> elem(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1 +0,0 @@
|
|||||||
FOOTER
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
FROM B
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
FROM A
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
FROM B
|
|
||||||
Reference in New Issue
Block a user