Compare commits
6 Commits
a5ac74db91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f99e139fa5 | |||
| 1914b05f39 | |||
| b09b14cc03 | |||
| 721b1ae626 | |||
| f7a4a9512c | |||
| 141c2bfc89 |
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 *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.**
|
||||||
|
|
||||||
|
|||||||
574
CODESMELL.md
574
CODESMELL.md
@@ -1,574 +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~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-27
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/search.ex`** — Already addressed by CSM-006; `batch_insert_post_index` and `batch_insert_media_index` use multi-row SQL INSERT with chunking.
|
|
||||||
- **`lib/bds/embeddings.ex`** — Replaced `Enum.each` + per-post `sync_post_if_enabled` (which did N individual `Repo.get_by` reads + N individual `Repo.insert_or_update` writes) in three bulk functions:
|
|
||||||
- `rebuild_project/2` — preloads all keys via `preload_keys_by_post_id/1`, computes rows with `compute_key_data/3`, batch-upserts with `batch_upsert_keys/1`.
|
|
||||||
- `repair_posts/2` — same pattern with `preload_keys_by_post_id/2` scoped to target post IDs.
|
|
||||||
- `index_unindexed/1` — same pattern, eliminating per-post `Repo.get_by` lookups.
|
|
||||||
- Added `preload_keys_by_post_id/1` and `/2` — single-query key preload into a map by post_id.
|
|
||||||
- Added `max_label_value/0` — reads max label once instead of per-post `next_label()` queries.
|
|
||||||
- Added `compute_key_data/3` — resolves body, hashes, embeds (if needed), returns `:skip` or `{:upsert, row}`.
|
|
||||||
- Added `batch_upsert_keys/1` — multi-row `INSERT INTO embedding_keys ... ON CONFLICT(label) DO UPDATE` with 199-row chunking (SQLite 999-param limit ÷ 5 columns).
|
|
||||||
- `sync_post_if_enabled/2` retained for single-post `sync_post/1` path (CRUD operations).
|
|
||||||
- Added 11 tests in `test/bds/csm033_batch_inserts_test.exs`: source-level assertions (no Enum.each+sync_post_if_enabled, batch_upsert_keys present, preload present, ON CONFLICT upsert, compute_key_data used), search.ex batch verification, and functional tests (index 5 posts, rebuild updates stale keys, repair targets subset, skip on matching hash).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-034 — `File.read!` / `File.write!` Without Error Handling~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-27
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/preview_assets.ex`** — `generated_outputs/0`: Replaced `File.read!` with `File.read` inside `Enum.flat_map`, silently skipping files that become unreadable between `Path.wildcard` and read (TOCTOU race).
|
|
||||||
- **`lib/bds/templates.ex`** — `upsert_template_from_file/3`: Replaced `File.read!` and pattern-matched `Frontmatter.parse_document` with a `with` chain returning `{:ok, template} | {:error, reason}`. Replaced `Repo.insert_or_update!` with `Repo.insert_or_update` to propagate changeset errors.
|
|
||||||
- **`lib/bds/templates.ex`** — Updated all three callers: `rebuild_templates_from_files` logs a warning and skips bad files, `sync_template_from_file` and `import_orphan_template_file` map errors to `{:error, :not_found}`.
|
|
||||||
- **`lib/bds/release_packaging.ex`** — Already fixed by CSM-030 (`File.write!` → `File.write`).
|
|
||||||
- Added 8 tests in `test/bds/csm034_file_read_bang_test.exs`: source-level assertions for no bang file ops in all three files, functional tests for rebuild skipping bad templates, sync returning error on deleted file, import returning error on missing file, and preview_assets returning valid output tuples.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-035 — Process Dictionary (`Process.get/put`) Usage~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-27
|
|
||||||
- **What was done:**
|
|
||||||
- **`lib/bds/desktop/ui_locale.ex`** — Added explicit **Invariant** section to `@moduledoc` documenting that every code path evaluating HEEx templates with `translated/1,2` must call `UILocale.put/1` before template evaluation. Lists all three render boundaries: `ShellLive.render/1`, `SidebarComponents.sidebar_content/1`, and `MenuBar.mount/1` + `handle_info({:set_ui_locale, _})`.
|
|
||||||
- Verified no raw `Process.put(:bds_ui_locale, ...)` or `Process.get(:bds_ui_locale)` exists outside `ui_locale.ex`.
|
|
||||||
- Added 9 tests in `test/bds/csm035_process_dict_test.exs`: source-level assertions that no raw Process.put/get/delete for `:bds_ui_locale` exists outside the module, render boundary assertions that `ShellLive.render/1`, `sidebar_content/1`, and `MenuBar.mount/1` call `UILocale.put` before template evaluation, and functional tests for `put/1`, `current/0`, and `with_locale/2` (including nil-restore behavior).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ~~CSM-036 — Missing `@impl true` on GenServer Callbacks~~ ✅ FIXED
|
|
||||||
- **Fixed:** 2026-05-27
|
|
||||||
- **What was done:**
|
|
||||||
- Added `@impl true` before all four `handle_call` clauses that were missing it in `lib/bds/publishing.ex`: `{:update_job, ...}`, `{:should_upload_scp_file, ...}`, `{:mark_uploaded_scp_file, ...}`, and `{:upload_site, ...}`.
|
|
||||||
- No `handle_cast`, `handle_info`, or `terminate` callbacks exist in this module; only `handle_call` needed fixing.
|
|
||||||
- Added 2 tests in `test/bds/csm036_impl_true_test.exs`: source-level assertion that every `handle_call` clause is preceded by `@impl true`, and a guard test for any future `handle_cast`/`handle_info`/`terminate` callbacks.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
43
SPECGAPS.md
43
SPECGAPS.md
@@ -22,28 +22,29 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
|
|||||||
| 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 |
|
||||||
|
|
||||||
### 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 +61,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 +98,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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -399,6 +400,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 +616,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
|
||||||
|
|||||||
@@ -116,6 +116,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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -362,6 +362,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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
1404
priv/gettext/ui.pot
1404
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;
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,19 +143,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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -3998,7 +3998,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 +4043,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 +4245,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 +4565,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 +4629,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 +4662,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 +4935,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
|
||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
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" => 153,
|
||||||
|
"fr/default.po" => 0,
|
||||||
|
"fr/render.po" => 0,
|
||||||
|
"fr/ui.po" => 153,
|
||||||
|
"it/default.po" => 0,
|
||||||
|
"it/render.po" => 0,
|
||||||
|
"it/ui.po" => 153,
|
||||||
|
"es/default.po" => 0,
|
||||||
|
"es/render.po" => 0,
|
||||||
|
"es/ui.po" => 153
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user