Compare commits

...

17 Commits

Author SHA1 Message Date
dfb2f8870b chore: some stupid whitelistings for claude code dumbness 2026-05-30 14:21:55 +02:00
f0919f24a5 B1-4: distill Style as its own :style singleton tab in editor_settings spec 2026-05-30 14:21:28 +02:00
72f2c829ca B1-3: distill Technology, MCP, and Data Maintenance settings sections into editor_settings spec 2026-05-30 14:17:59 +02:00
7c7f629dd2 B1-2: distill auto-translation system into translation.allium spec 2026-05-30 14:14:02 +02:00
dd760d0f2b B1-1: distill 9 inline surface types into editor_chat.allium spec 2026-05-30 13:39:57 +02:00
fb794ae833 Fix C-1: add cache_read_tokens/cache_write_tokens to schema.allium ChatMessage 2026-05-30 13:35:06 +02:00
df0ae6a41b D1-18: HomeItemProtection — Home menu item cannot be moved, reordered, or deleted 2026-05-30 13:33:40 +02:00
e515cfacc6 D1-17: add tests for protected categories deletion rejection (article/aside/page/picture) 2026-05-30 13:25:42 +02:00
7e9cc72e1f fix: D1-16 cancel orphaned debounce timer so index saves coalesce; add tests 2026-05-30 09:48:32 +02:00
257a06e5d1 feat: D1-15 implement drag-and-drop image chain (import+thumbnails+link+insert) with tests 2026-05-30 09:34:41 +02:00
1b37f1fcec test: D1-14 cover ReplaceMediaFileSideEffects file replace + sync thumbnail regen 2026-05-30 09:18:36 +02:00
56caa653bb test: D1-13 cover DiscardPostChangesSideEffects FTS re-sync after discard 2026-05-30 09:15:15 +02:00
925fe97007 test: D1-12 enforce BoundedToolLoop via config.chat_max_tool_rounds 2026-05-30 09:13:03 +02:00
d688c61b0e test: D1-11 cover ChatContextTruncation invariant in chat requests 2026-05-30 09:08:51 +02:00
8db7bcf357 test: D1-10 cover TransformPipelineContinuation with first-transform failure 2026-05-30 09:03:09 +02:00
2bed225133 style: fix pre-existing formatting drift across codebase 2026-05-30 09:00:29 +02:00
7045b10738 fix: A1-17 route bds2://new-post deep links into transform pipeline and draft creation 2026-05-30 08:58:22 +02:00
83 changed files with 4088 additions and 1928 deletions

View File

@@ -21,7 +21,9 @@
"Bash(allium --help)",
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)"
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(echo \"EXIT: $?\")",
"Bash(echo \"exit: $?\")"
]
}
}

View File

@@ -28,7 +28,7 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| A1-14c | ~~Embedding model runs on CPU only; no Apple GPU acceleration~~ | embedding.allium invariant NativeAcceleratedExecution | `Backends.Neural` now selects the defn compiler at serving-build time: Apple GPU via EMLX (MLX/Metal) on arm64 macOS, EXLA-CPU elsewhere | **Resolved:** added `{:emlx, "~> 0.2.0"}` dep (ships precompiled MLX binaries; EMLX 0.2.0 implements both `EMLX.Backend` and the `Nx.Defn.Compiler` behaviour, GPU-default); `Backends.Neural` gained a pure `select_accelerator/3` policy (`:auto` prefers EMLX only when available **and** on Apple Silicon; explicit `:emlx`/`:exla` honoured; forced `:emlx` degrades to EXLA when unavailable so misconfigured hosts still run), `current_accelerator/0`, and `defn_options/1`; `build_serving` places params on `{EMLX.Backend, device: :gpu}` and compiles with `EMLX` for the EMLX path, keeps `EXLA` otherwise; new `accelerator: :auto` config key; spec `NativeAcceleratedExecution` + `EmbeddingModel` updated; PLT app added; 7 tests added (offline — test config still uses the InApp stub). |
| A1-15 | ~~Preview vs generation content source strategy undocumented~~ | preview.allium (no invariant), generation.allium (no invariant) | Generation uses only published .md file content (`Generation.Data` snapshots set `content: nil`); preview includes published+draft posts and prefers DB content over file (`Preview.Router` queries `:published`/`:draft`, uses `editor_body`) | **Resolved:** added `PreviewDraftOverlay` invariant to preview.allium and `GenerationPublishedOnly` invariant to generation.allium; both cross-reference each other; code already correct, 3 tests added for draft-in-preview behavior |
| A1-16 | ~~Public project content + data_path discovery not compliant with storage-location spec~~ | project.allium `PublicContentLivesInProjectFolder` / `PrivateArtifactsLiveInOsAppDir` / `DataPathNotPersistedInProjectJson` / `DiscoverProjectDataPath` | Public content now lives under a per-user default content location, never the repo | **Resolved:** `project_data_dir/1` drops the `priv/data/projects/<id>` repo fallback — a project without an explicit `data_path` resolves to `default_content_root()/<id>` (configurable via `:default_content_root`, else `~/bds`), never the repo or `private_dir`; the `default` project is now created on first launch with an explicit `data_path` under that location and its folder is `mkdir`'d (`PublicContentLivesInProjectFolder`); added `Projects.private_dir/0`, `default_content_root/0`, and a machine-local project registry (`registry_path/0``project_registry.json` under `private_dir`, written on create/ensure-default, removed on delete) that remembers each project's folder without embedding it in `meta/project.json` (`DataPathNotPersistedInProjectJson`/`DiscoverProjectDataPath` — already satisfied since `project.json` never serializes `data_path`); `delete_project` removes app-managed folders (those under `default_content_root`) but preserves user-chosen external folders; committed `priv/data/projects/default/` content removed from the repo and `/priv/data/projects/` git-ignored; test config redirects `:default_content_root` to a temp dir; 4 tests added (default folder outside repo/private, no-repo fallback, registry round-trip, registry cleanup on delete). |
| A1-17 | `bds2://new-post` blogmark deep link is never received or routed | script.allium:74 (`BlogmarkReceived`), script.allium:233-268 (`ExecuteTransform`/`TransformTrigger`), editor_settings.allium:141-143 (`BookmarkletCopy`) | Only the bookmarklet JS string is generated (`app_shell.ex:48`, now emitting the `bds2://` scheme so it does not clash with the legacy app's `bds://`); nothing handles the resulting `bds2://new-post?title=&url=` deep link. The `ExecuteTransform` engine now exists (`BDS.Scripts.Transforms.run/3`, A1-9) but no code emits `BlogmarkReceived(data)`, so the pipeline is never triggered and no post candidate is created. | **Open:** register a `bds2://` URL-scheme handler in the desktop layer, parse `new-post` query params into a candidate `{title, content?, tags, categories, url}`, run `BDS.Scripts.Transforms.run/3`, then create a draft post (defaulting category from `blogmark_category`) and open it in the editor; surface accepted transform toasts. |
| A1-17 | ~~`bds2://new-post` blogmark deep link is never received or routed~~ | script.allium:74 (`BlogmarkReceived`), script.allium:233-268 (`ExecuteTransform`/`TransformTrigger`), editor_settings.allium:141-143 (`BookmarkletCopy`) | `BDS.Desktop.DeepLink` subscribes to OS `{:open_url, ...}` events and routes `bds2://` links to the shell; `BDS.Blogmark` parses + runs transforms + creates the draft; `ShellLive` opens it and surfaces toasts | **Resolved:** added `BDS.Blogmark.parse_deep_link/1` (parses `bds2://new-post?title=&url=&content=&tags=&categories=` into a `{title, content?, tags, categories, url}` candidate, rejecting unsupported scheme/action) and `receive_deep_link/3` (runs `BDS.Scripts.Transforms.run/3` then creates a draft post, defaulting categories from `blogmark_category` only when neither the link nor a transform set one); `BDS.Desktop.DeepLink` GenServer subscribes to `Desktop.Env` `{:open_url, [url]}` events and broadcasts `{:blogmark_deep_link, url}` over PubSub (added to the desktop supervision tree, guarded for headless/test); `ShellLive.handle_info({:blogmark_deep_link, url})` runs the import against the active project, opens the new post tab (route `"post"`), and appends transform toasts/errors to the output panel (warns when no project is open). OS-level scheme registration (macOS `CFBundleURLTypes` in the packaged `Info.plist`) is documented in `BDS.Desktop.DeepLink` — no app-bundle pipeline exists in the repo yet. i18n added for `Blogmark`/"Open a project before importing a blogmark." (de/fr/it/es); 13 tests added (5 parse, 5 receive incl. transforms + default category, 3 deep-link routing, 1 shell create+open). |
### A2. Spec Should Update (code is normative)
@@ -60,10 +60,10 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| ID | Behavior | Code Location | Path |
|---|---|---|---|
| B1-1 | Chat inline surfaces (9 types: card, chart, form, list, metric, mindmap, table, tabs, text/json) | `lib/bds/ui/chat/tool_surfaces.ex:6-15` | Distill into spec |
| B1-2 | Auto-translation system (AutoTranslation.maybe_schedule, media cascade, batch fill) | `lib/bds/posts/auto_translation.ex` | Distill into spec |
| B1-3 | 3 extra settings sections (Technology, MCP, Data Maintenance) | `lib/bds/ui/settings_editor/` | Distill into spec |
| B1-4 | Style/Theme as separate tab (`:style`), not settings section | `lib/bds/ui/style_editor.ex` | Distill into spec |
| ~~B1-1~~ | ~~Chat inline surfaces (9 types: card, chart, form, list, metric, mindmap, table, tabs, text/json)~~ | `tool_surfaces.ex` | **Resolved:** added InlineSurface value type with all 9 discriminators, supporting value types (SurfaceAction, ChartSeries, MindmapNode, FormField, FieldOption, TabPanel), inline_surfaces on ChatMessage, InlineSurfaceRendering guarantee, and surface_form_debounce_ms config to editor_chat.allium; updated StructuredRenderTools invariant in ai.allium to list all 9 types |
| ~~B1-2~~ | ~~Auto-translation system (AutoTranslation.maybe_schedule, media cascade, batch fill)~~ | `lib/bds/posts/auto_translation.ex` | **Resolved:** distilled into translation.allium — added `AutoTranslationControlSurface` (PostSavedForAutoTranslation reactive + FillMissingTranslationsRequested batch triggers), three rules (`ScheduleAutoTranslation` draft-per-missing-language + media cascade, `AutoTranslatePost` upsert/auto-publish primitive, `AutoTranslateMediaCascade` linked-media per-language tasks, `FillMissingTranslations` published-only batch emitting `ProgressReported` + `FillMissingTranslationsCompleted`), three invariants (`AutoTranslationGatedByEndpoint`, `AutoTranslationSkipsDoNotTranslate`, `AutoTranslationOnlyMissingLanguages`), and `auto_translation_task_group_name` config; `allium check` passes |
| ~~B1-3~~ | ~~3 extra settings sections (Technology, MCP, Data Maintenance)~~ | `lib/bds/desktop/shell_live/settings_editor/` | **Resolved:** distilled into editor_settings.allium — added `SettingsTechnologySection` (semantic_similarity toggle + read-only scripting-runtime note, saved with project metadata), `SettingsDataSection` (rebuild_targets), and `technology_section`/`data_section` on `SettingsView`; reconciled `SettingsMCPSection`/`MCPAgentRow` to code (dropped non-existent status badge; added `is_supported`/`config_path`; only Claude Code + GitHub Copilot supported); updated TechnologySection/MCPSection/DataMaintenanceSection guarantees (7 rebuild buttons incl. Rebuild Embedding Index) and SettingsRebuild rule entity_type (+embedding); `allium check` passes |
| ~~B1-4~~ | ~~Style/Theme as separate tab (`:style`), not settings section~~ | `lib/bds/desktop/shell_live/settings_editor/style_editor.ex` | **Resolved:** editor_settings.allium Style view section now frames it as its own `style` singleton tab (cross-ref tabs.allium), explicitly NOT a SettingsView collapsible section; added `SeparateTab` guarantee (requires active project), documented 20 named Pico themes and the theme display-name transform ("-"→" ", capitalise); `allium check` passes |
| B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity |
| B1-6 | Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering) | `lib/bds/rendering/` | Distill into spec |
| B1-7 | 404.html generation | `lib/bds/generation/outputs.ex:344-345` | Add to generation.allium |
@@ -102,8 +102,8 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Conflict | Resolution | Path |
|---|---|---|---|
| C-1 | schema.allium ChatMessage has no cache tokens; ai.allium ChatMessage has `cache_read_tokens`/`cache_write_tokens` | Code has cache tokens → align schema.allium with ai.allium | Update schema.allium |
|---|---|---|---|---|
| C-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 | **Resolved:** added cache_read_tokens + cache_write_tokens to ChatMessage entity and ChatMessageRecordSurface in schema.allium |
| C-2 | ~~media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it~~ | Code writes `linkedPostIds` → add to frontmatter.allium | **Resolved:** linkedPostIds added to MediaSidecar in frontmatter.allium (with A2-15) |
| C-3 | ~~translation.allium says status/timestamps omitted; frontmatter.allium TranslationFrontmatter defines only 5 fields; code writes 8+ fields~~ | Code writes status/timestamps → update both specs to match code | **Resolved:** both specs updated (see A2-5) |
@@ -124,15 +124,15 @@ All reconciled to follow code. Specs must be self-consistent and match code.
| D1-7 | ~~LiquidOperatorSubset~~ | template.allium:210 | **Resolved:** `LiquidParser.validate/1` now walks the parsed AST for `{:op, _}` nodes and rejects any comparison operator outside the allowed `==`/`>` subset (`!=`, `<`, `>=`, `<=`, `contains`), sharing the publish gate and MCP `validate_template` surface with the tag/filter checks; spec `LiquidOperatorSubset` annotated with enforcement note; 10 tests added (5 unsupported operators rejected at publish, 5 supported `==`/`>`/`and`/`or`/bare-truthy expressions accepted). |
| D1-8 | ~~MacroTimeout guarantee~~ | script.allium:94-95 | **Resolved:** added test in `api_test.exs` — an infinite-loop `render()` macro run with `max_reductions: :none` (forces the luerl sandbox onto its wall-clock path) and a 150ms `timeout` returns `{:error, :timeout}` and terminates within budget (<2s), proving the macro is killed near its budget rather than the default multi-minute script timeout |
| D1-9 | ~~ExecuteTransform rule (pipeline, ordering, toast budget)~~ | script.allium:229-263 | **Resolved:** the `ExecuteTransform` rule had no engine — added `BDS.Scripts.Transforms.run/3` (+ `Scripts.list_transform_scripts/1` ordered by updated_at→slug→id and `Scripts.resolved_content/1`). The pipeline runs enabled project transforms sequentially on the blogmark candidate with a `{source="blogmark", url}` context, captures per-script errors without rolling back the last valid candidate (TransformPipelineContinuation), and enforces the toast budget (`transform_max_toasts_per_script`/`transform_max_toasts_total`/`transform_max_toast_length`, new config keys). 6 tests added (ordering, project/disabled scoping, continuation, context, per-script + total toast caps with truncation). Deep-link OS routing into this engine remains future work. |
| D1-10 | TransformPipelineContinuation | script.allium:247-249 | Write test: error in transform doesn't halt pipeline |
| D1-11 | ChatContextTruncation invariant | ai.allium:375-379 | Write test: long chat history trimmed to context window |
| D1-12 | BoundedToolLoop enforcement | ai.allium:381-385 | Write test: tool rounds bounded by chat_max_tool_rounds |
| D1-13 | DiscardPostChangesSideEffects | engine_side_effects.allium:99-104 | Write test: FTS updated after discard |
| D1-14 | ReplaceMediaFileSideEffects | engine_side_effects.allium:128-134 | Write test: file replaced, thumbnails regenerated |
| D1-15 | Drag-and-drop image chain | action_patterns.allium:84-103 | Write integration test |
| D1-16 | DebouncedPersistence (5s) | embedding.allium:204-208 | Write test: index persistence debounced |
| D1-17 | Protected categories cannot be deleted | editor_settings.allium:81-84 | Write test: article/aside/page/picture deletion rejected |
| D1-18 | HomeItemProtection (menu) | editor_misc.allium:206-209 | Write test: cannot move/reorder/delete Home |
| D1-10 | ~~TransformPipelineContinuation~~ | script.allium:247-249 | **Resolved:** added focused test in `transforms_test.exs` — a failing *first* transform (no prior valid state) does not halt the pipeline: the original input survives, a later enabled transform still runs against it, and every failure is captured per-script in pipeline order tagged with its slug |
| D1-11 | ~~ChatContextTruncation invariant~~ | ai.allium:375-379 | **Resolved:** test added in `ai_test.exs` — a catalog model with a 2,000-token context window plus 40 large seeded turns forces truncation; the captured chat request keeps the system prompt as the first message, drops the oldest pairs first (surviving markers form a contiguous newest suffix, oldest absent), and always retains the newest user turn |
| D1-12 | ~~BoundedToolLoop enforcement~~ | ai.allium:381-385 | **Resolved:** the round cap is now read from `config.chat_max_tool_rounds` (`config :bds, :chat, max_tool_rounds: 10`) via `chat_max_tool_rounds/0` in chat.ex instead of a hardcoded attribute, matching the spec wording; test added in `ai_test.exs` — a `LoopingToolRuntime` that always returns another tool call (never a final answer) with `max_tool_rounds: 3` ends with `{:error, %{kind: :tool_loop_exhausted}}` after exactly 3 runtime calls (the `rounds_left == 0` round short-circuits before contacting the runtime) |
| D1-13 | ~~DiscardPostChangesSideEffects~~ | engine_side_effects.allium:99-104 | **Resolved:** test added in `posts_test.exs` — a published post is dirtied with unsaved title/content edits (re-indexing the dirty text in FTS), then `discard_post_changes/1` restores the published file version (status=published, content=nil, original title) and re-syncs the FTS index so the published terms are searchable again and the discarded edits are gone |
| D1-14 | ~~ReplaceMediaFileSideEffects~~ | engine_side_effects.allium:128-134 | **Resolved:** 3 tests added in `media_test.exs``replace_media_file/2` copies the new image over the existing path, updates the row (checksum/size/width/height), and regenerates all thumbnails synchronously (present immediately after the call, no `.bak` backup left); identical-checksum replace is a no-op (`{:ok, nil}`); unknown media id returns `{:error, :not_found}` |
| D1-15 | ~~Drag-and-drop image chain~~ | action_patterns.allium:84-103 | **Resolved:** the chain had no handler — added `BDS.Desktop.ShellLive.EditorImageDrop` (`import_and_link/3` runs steps 1-4: import media + synchronous thumbnails + link to post + return `![](bds-media://id)` markdown; `enrich/3` runs background steps 5-6: AI analysis auto-applied with no modal + auto-translate cascade when `do_not_translate == false`). `PostEditor.handle_event("editor_image_dropped", ...)` runs the synchronous chain (works offline since import isn't AI), pushes the cursor insert, and spawns `enrich` only when airplane mode is off. MonacoEditor JS hook captures image drops on the editor surface and pushes the file path (`phx-target={@myself}` routes the hook event to the component); i18n for de/fr/it/es. 3 tests added (module chain incl. thumbnails+link+markdown, non-image link form, full LiveView drop in airplane mode asserting import/link/insert with no AI metadata). |
| D1-16 | ~~DebouncedPersistence (5s)~~ | embedding.allium:213-217 | **Resolved:** 3 tests added in `embeddings_test.exs` (DebouncedPersistence describe): `Index.put/3` schedules a per-project save `timer` with ~5s remaining (>4000ms, <=5000ms) instead of writing immediately (no `embeddings.usearch` on disk yet); rapid `put`s coalesce (each reschedules the single timer, the previous timer is cancelled so `Process.read_timer` returns false, and still no file after two writes); when the `{:save, project_id}` debounce message fires the index is persisted to disk and the pending `timer` cleared. The coalescing test exposed a real bug: `handle_call({:put, ...})` replaced the stored entry with `build_entry/2`'s fresh `timer: nil` entry before `schedule_save/2` ran, orphaning the previous debounce timer (left to fire a redundant save) instead of cancelling it; fixed via `cancel_pending_save/2` so bulk `put`s collapse to one deferred write |
| D1-17 | ~~Protected categories cannot be deleted~~ | editor_settings.allium:81-84 | **Resolved:** 5 tests added in managed_categories_test.exs covering protected_category?/1 classification and remove_category/4 rejection for all 4 protected categories (article, aside, page, picture) plus non-protected deletion allowed |
| D1-18 | ~~HomeItemProtection (menu)~~ | editor_misc.allium:206-209 | **Resolved:** 13 tests added for TreePredicates (can_move_up?/can_move_down?/can_indent?/can_unindent? return false for Home, can_delete? false for Home, true for non-Home) + TreeOps (move_selected/indent_selected/unindent_selected/delete_selected no-op for Home, drop_selected no-op when drag item is Home, non-Home operations still work); code guards added to all predicate and operation functions |
### D2. No Test Coverage (MEDIUM priority — rules/behaviors)
@@ -190,10 +190,10 @@ All reconciled to follow code. Specs must be self-consistent and match code.
1. ~~**A1-1 through A1-15**~~ — all resolved: auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown, real embedding model, HNSW ANN index, Apple GPU/EMLX acceleration (A1-14c), and preview/generation content strategy (A1-15)
1b. ~~**A1-16**~~ — storage-location compliance resolved: public content now lives under a per-user default content location (never the repo/private dir), `priv/data/projects/<id>` fallback dropped, machine-local project registry added, committed default project content removed from repo
1c. **A1-17** — blogmark deep-link OS handler not implemented: the `ExecuteTransform` engine exists but nothing receives `bds2://new-post` and emits `BlogmarkReceived(data)` to trigger it
1c. ~~**A1-17**~~ — blogmark deep-link handler resolved: `BDS.Desktop.DeepLink` receives OS `bds2://` URL events and `BDS.Blogmark` parses them, runs the transform pipeline, and creates+opens a draft post (macOS `Info.plist` scheme registration documented, pending an app-bundle pipeline)
2. **D1-1 through D1-18** — untested invariants/guarantees
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
4. **B1-1 through B1-6** — major code behaviors missing from spec
4. **B1-1 through B1-6** — major code behaviors missing from spec (B1-1, B1-2, B1-3, B1-4 resolved)
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
6. **D2-1 through D2-17** — untested rules
7. **D3-1 through D3-11** — partial test coverage

View File

@@ -118,6 +118,36 @@ export const MonacoEditor = {
}, 120);
};
this.dropEvent = this.el.dataset.monacoDropEvent || "";
this.dropPostId = this.el.dataset.monacoDropPostId || "";
this.handleDragOver = (event) => {
if (event.dataTransfer && Array.from(event.dataTransfer.types || []).includes("Files")) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
};
this.handleDrop = (event) => {
if (!this.dropEvent || !event.dataTransfer) {
return;
}
const files = Array.from(event.dataTransfer.files || []);
const images = files.filter((file) => (file.type || "").startsWith("image/") && file.path);
if (images.length === 0) {
return;
}
event.preventDefault();
event.stopPropagation();
images.forEach((file) => {
this.pushEvent(this.dropEvent, { "post-id": this.dropPostId, path: file.path });
});
};
this.handleInsert = ({ id, content }) => {
if (!this.editor || !content || String(id) !== String(this.editorId)) {
return;
@@ -197,6 +227,11 @@ export const MonacoEditor = {
if (this.insertEvent) {
this.handleEvent(this.insertEvent, this.handleInsert);
}
if (this.dropEvent) {
this.el.addEventListener("dragover", this.handleDragOver);
this.el.addEventListener("drop", this.handleDrop);
}
})
.catch((error) => {
console.error("Failed to load Monaco editor", error);
@@ -232,6 +267,12 @@ export const MonacoEditor = {
window.clearTimeout(this.syncTimer);
this.visibleSizeObserver?.disconnect();
this.changeSubscription?.dispose();
if (this.dropEvent) {
this.el.removeEventListener("dragover", this.handleDragOver);
this.el.removeEventListener("drop", this.handleDrop);
}
unregisterMonacoEditor(this.editorId || this.el.id);
this.editor?.dispose();
}

View File

@@ -63,6 +63,8 @@ config :bds, :scripting,
transform_max_toasts_total: 20,
transform_max_toast_length: 300
config :bds, :chat, max_tool_rounds: 10
config :bds, :embeddings,
backend: BDS.Embeddings.Backends.Neural,
model_id: "Xenova/multilingual-e5-small",

View File

@@ -10,7 +10,8 @@ if config_env() == :prod do
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1")
# Persist downloaded embedding model files alongside the database data dir.
config :bumblebee, :cache_dir,
System.get_env("BDS_MODEL_CACHE_DIR") ||
Path.join(Path.dirname(Path.expand(database_path)), "models")
config :bumblebee,
:cache_dir,
System.get_env("BDS_MODEL_CACHE_DIR") ||
Path.join(Path.dirname(Path.expand(database_path)), "models")
end

View File

@@ -411,7 +411,7 @@ defmodule BDS.AI.Chat do
tools,
runtime,
opts,
@chat_max_tool_rounds
chat_max_tool_rounds()
),
{:ok, reply} <-
maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
@@ -752,6 +752,14 @@ defmodule BDS.AI.Chat do
ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
end
# BoundedToolLoop: the tool-calling round count is capped by
# config.chat_max_tool_rounds (falling back to the built-in default).
defp chat_max_tool_rounds do
:bds
|> Application.get_env(:chat, [])
|> Keyword.get(:max_tool_rounds, @chat_max_tool_rounds)
end
defp chat_system_prompt(project_id, tools) do
base = get_setting("ai.system_prompt") || @default_system_prompt

View File

@@ -27,7 +27,9 @@ defmodule BDS.AI.ChatConversation do
def changeset(conversation, attrs) do
conversation
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
|> cast(
attrs,
[:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
empty_values: [nil]
)
|> validate_required([:id, :title, :created_at, :updated_at])

View File

@@ -25,21 +25,22 @@ defmodule BDS.Application do
@impl true
def start(_type, _args) do
children = [
{Phoenix.PubSub, name: BDS.PubSub},
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
BDS.Repo,
BDS.RepoBootstrap,
BDS.Tasks,
BDS.Preview,
BDS.Publishing,
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
BDS.Scripting.JobStore,
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
BDS.Scripting.JobSupervisor,
BDS.Embeddings.Index
] ++ embedding_children() ++ desktop_children(current_env())
children =
[
{Phoenix.PubSub, name: BDS.PubSub},
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
BDS.Repo,
BDS.RepoBootstrap,
BDS.Tasks,
BDS.Preview,
BDS.Publishing,
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
BDS.Scripting.JobStore,
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
BDS.Scripting.JobSupervisor,
BDS.Embeddings.Index
] ++ embedding_children() ++ desktop_children(current_env())
opts = [strategy: :one_for_one, name: BDS.Supervisor]
Supervisor.start_link(children, opts)
@@ -72,7 +73,8 @@ defmodule BDS.Application do
[
{Desktop.Window, window_opts},
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher)
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher),
{BDS.Desktop.DeepLink, []}
]
end
end

150
lib/bds/blogmark.ex Normal file
View File

@@ -0,0 +1,150 @@
defmodule BDS.Blogmark do
@moduledoc """
Receives `bds2://new-post` blogmark deep links and turns them into draft posts
(spec: script.allium `BlogmarkReceived`/`ExecuteTransform`,
editor_settings.allium `BookmarkletCopy`).
The browser bookmarklet (`BDS.Scripting.Capabilities.AppShell`) navigates to a
`bds2://new-post?title=&url=` URL. The desktop layer hands that URL here, where
it is parsed into a post candidate, run through the enabled transform pipeline
(`BDS.Scripts.Transforms`), and finally persisted as a draft post — defaulting
the category from the project's `blogmark_category` setting when neither the
link nor a transform supplied one.
The `bds2://` scheme deliberately differs from the legacy app's `bds://` scheme
so the two installs do not fight over the same registration.
"""
alias BDS.Metadata
alias BDS.Posts
alias BDS.Scripts.Transforms
@scheme "bds2"
@new_post_action "new-post"
@type candidate :: %{required(String.t()) => term()}
@type receive_result :: %{
post: Posts.Post.t(),
toasts: [String.t()],
errors: [%{slug: String.t() | nil, reason: term()}]
}
@doc """
Parses a `bds2://new-post` deep link into a post candidate map.
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
`tags` and `categories`, or an error when the scheme or action is unsupported.
"""
@spec parse_deep_link(String.t()) ::
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
def parse_deep_link(url) when is_binary(url) do
case URI.parse(url) do
%URI{scheme: @scheme, host: @new_post_action, query: query} ->
{:ok, candidate_from_query(query)}
%URI{scheme: @scheme} ->
{:error, :unsupported_action}
%URI{scheme: nil} ->
{:error, :invalid_url}
%URI{} ->
{:error, :unsupported_scheme}
end
end
@doc """
Receives a blogmark deep link for `project_id`: parses it, runs the transform
pipeline, and creates a draft post from the resulting candidate.
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
budget-enforced transform messages and `errors` records any failed transforms.
"""
@spec receive_deep_link(String.t(), String.t(), keyword()) ::
{:ok, receive_result()} | {:error, term()}
def receive_deep_link(project_id, url, opts \\ [])
when is_binary(project_id) and is_binary(url) and is_list(opts) do
with {:ok, candidate} <- parse_deep_link(url),
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
Transforms.run(project_id, candidate, opts),
data <- apply_default_category(project_id, data),
{:ok, post} <- create_draft(project_id, data) do
{:ok, %{post: post, toasts: toasts, errors: errors}}
end
end
defp candidate_from_query(query) do
params = URI.decode_query(query || "")
%{
"title" => Map.get(params, "title", "") |> to_string(),
"url" => optional(params, "url"),
"content" => optional(params, "content"),
"tags" => list_param(params, "tags"),
"categories" => list_param(params, "categories")
}
end
defp optional(params, key) do
case Map.get(params, key) do
nil -> nil
"" -> nil
value -> value
end
end
defp list_param(params, key) do
case Map.get(params, key) do
value when is_binary(value) ->
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
_ ->
[]
end
end
# Apply the project default category only when neither the deep link nor a
# transform produced one, so explicit categories always win.
defp apply_default_category(project_id, data) do
case Map.get(data, "categories") do
categories when is_list(categories) and categories != [] ->
data
_ ->
case default_category(project_id) do
nil -> data
category -> Map.put(data, "categories", [category])
end
end
end
defp default_category(project_id) do
case Metadata.get_project_metadata(project_id) do
{:ok, %{blogmark_category: category}} when is_binary(category) and category != "" ->
category
_ ->
nil
end
end
defp create_draft(project_id, data) do
Posts.create_post(%{
project_id: project_id,
title: Map.get(data, "title", ""),
content: optional_string(Map.get(data, "content")),
tags: string_list(Map.get(data, "tags")),
categories: string_list(Map.get(data, "categories"))
})
end
defp optional_string(value) when is_binary(value), do: value
defp optional_string(_value), do: nil
defp string_list(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
defp string_list(_other), do: []
end

View File

@@ -0,0 +1,74 @@
defmodule BDS.Desktop.DeepLink do
@moduledoc """
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
shell (spec: script.allium `BlogmarkReceived`).
On macOS the app bundle registers `bds2://` as a custom URL scheme (see the
`CFBundleURLTypes` entry in the packaged `Info.plist`). When the browser
bookmarklet navigates to `bds2://new-post?title=&url=`, the OS launches/raises
the app and `Desktop.Env` delivers an `{:open_url, [url]}` event. This
GenServer subscribes to those events and forwards recognised `bds2://` links to
the live shell over PubSub, where `BDS.Blogmark` turns them into draft posts.
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
installs do not contend for the same registration.
"""
use GenServer
require Logger
alias BDS.CliSync.Watcher
@scheme "bds2://"
def child_spec(opts) do
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}}
end
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@impl true
def init(opts) do
pubsub = Keyword.get(opts, :pubsub, BDS.PubSub)
topic = Keyword.get(opts, :topic, Watcher.topic())
subscribe_to_env()
{:ok, %{pubsub: pubsub, topic: topic}}
end
# Desktop.Env delivers OS events as {event_name, args} tuples.
@impl true
def handle_info({:open_url, [url | _rest]}, state) when is_binary(url) do
{:noreply, route(url, state)}
end
def handle_info(_message, state), do: {:noreply, state}
defp route(url, state) do
if String.starts_with?(url, @scheme) do
Phoenix.PubSub.broadcast(state.pubsub, state.topic, {:blogmark_deep_link, url})
else
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
end
state
end
# Desktop.Env is only present when the wx desktop adapter is running. Guard the
# subscribe so the GenServer can still start in headless/test configurations.
defp subscribe_to_env do
if Process.whereis(Desktop.Env) do
try do
Desktop.Env.subscribe()
catch
:exit, _reason -> :ok
end
end
:ok
end
end

View File

@@ -263,8 +263,13 @@ defmodule BDS.Desktop.Overlay do
def set_ai_suggestions_error(overlay, _error_message), do: overlay
defp normalize_ai_fields(fields) do
Enum.map(fields, fn %{key: key, label: label, current_value: current,
suggested_value: suggested, locked: locked} = field ->
Enum.map(fields, fn %{
key: key,
label: label,
current_value: current,
suggested_value: suggested,
locked: locked
} = field ->
%{
key: to_string(key),
label: label,

View File

@@ -5,7 +5,7 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML
alias BDS.{AI, BoundedAtoms, Metadata}
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
alias BDS.CliSync.Watcher
alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
@@ -717,6 +717,10 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, socket}
end
def handle_info({:blogmark_deep_link, url}, socket) when is_binary(url) do
{:noreply, handle_blogmark_deep_link(socket, url)}
end
def handle_info(message, socket) do
Bridges.handle_info(message, socket, bridges_callbacks())
end
@@ -1002,6 +1006,56 @@ defmodule BDS.Desktop.ShellLive do
defp create_sidebar_item(socket, kind),
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
# Receive a bds2://new-post blogmark deep link: run the transform pipeline,
# create a draft post, open it in the editor, and surface transform toasts.
defp handle_blogmark_deep_link(socket, url) do
title = dgettext("ui", "Blogmark")
case current_project_id(socket) do
project_id when is_binary(project_id) ->
case Blogmark.receive_deep_link(project_id, url) do
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
socket
|> reload_shell(socket.assigns.workbench)
|> open_sidebar_item(
%{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => post.slug
},
:pin
)
|> append_blogmark_toasts(title, toasts)
|> append_blogmark_errors(title, errors)
{:error, reason} ->
append_output_entry(socket, title, inspect(reason), url, "error")
end
_ ->
append_output_entry(
socket,
title,
dgettext("ui", "Open a project before importing a blogmark."),
url,
"warning"
)
end
end
defp append_blogmark_toasts(socket, title, toasts) do
Enum.reduce(toasts, socket, fn message, acc ->
append_output_entry(acc, title, message, nil, "info")
end)
end
defp append_blogmark_errors(socket, title, errors) do
Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc ->
append_output_entry(acc, title, inspect(reason), slug, "error")
end)
end
defp handle_file_picker_result(socket, {:ok, _media}),
do: refresh_content(socket, socket.assigns.workbench)

View File

@@ -103,7 +103,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
socket
) do
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
{:noreply, assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()}
{:noreply,
assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()}
end
def handle_event(
@@ -227,8 +229,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
build_data(socket)
socket.assigns.offline_mode ->
Notify.output(dgettext("ui", "Chat"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."), "info")
Notify.output(
dgettext("ui", "Chat"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
"info"
)
build_data(socket)

View File

@@ -0,0 +1,110 @@
defmodule BDS.Desktop.ShellLive.EditorImageDrop do
@moduledoc false
# Implements the drag-and-drop image chain described in
# action_patterns.allium DragDropImageChain (trigger: editor_post.allium
# PostDragDropImage). A single image file dropped on the post editor body
# runs four synchronous steps the user waits on, then two background steps
# whose results are auto-applied without a modal.
require Logger
alias BDS.{AI, Media, Metadata, Posts}
@doc """
Synchronous portion of the chain (steps 1-4):
1. importMedia(file) -> media record + file copy + base sidecar
2. generateThumbnails(media) -> small/medium/large/ai (done inside import_media)
3. linkMediaToPost(media, post) -> update sidecar linkedPostIds
4. caller inserts the returned markdown at the cursor
Returns `{:ok, media, markdown}` where `markdown` is the reference inserted at
the cursor. These steps are not AI activities, so they run regardless of
airplane mode.
"""
@spec import_and_link(String.t(), String.t(), String.t()) ::
{:ok, Media.Media.t(), String.t()} | {:error, term()}
def import_and_link(project_id, post_id, source_path) do
with {:ok, media} <-
Media.import_media(%{project_id: project_id, source_path: source_path}),
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
{:ok, media, markdown_for(media)}
end
end
@doc """
Markdown reference inserted at the cursor (step 4): `![](bds-media://id)` for
images, a plain link for other file types.
"""
@spec markdown_for(Media.Media.t()) :: String.t()
def markdown_for(media) do
if String.starts_with?(media.mime_type || "", "image/") do
"![](bds-media://#{media.id})"
else
"[#{media.original_name}](bds-media://#{media.id})"
end
end
@doc """
Background portion of the chain (steps 5-6), gated behind airplane mode:
5. aiImageAnalysis(media) -> results auto-applied to media metadata (no modal)
6. if auto-translate enabled (post.do_not_translate == false):
translateMediaMetadata(media, lang) for each blog language
Only runs for images. Failures are logged and never roll back the import.
"""
@spec enrich(Media.Media.t(), String.t(), String.t()) :: :ok
def enrich(media, post_id, language) do
if image?(media) do
with {: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
}) do
maybe_translate(media.id, post_id, language)
else
{:error, reason} ->
Logger.warning("Drag-drop AI analysis failed for #{media.id}: #{inspect(reason)}")
end
end
:ok
end
defp maybe_translate(media_id, post_id, language) do
post = Posts.get_post(post_id)
if post && not post.do_not_translate do
translate_targets(post.project_id, language)
|> Enum.each(fn target ->
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
})
{:error, reason} ->
Logger.warning(
"Drag-drop media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
)
end
end)
end
end
defp translate_targets(project_id, language) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
[metadata.main_language | metadata.blog_languages || []]
|> Enum.reject(&(&1 == language or is_nil(&1)))
|> Enum.uniq()
end
defp image?(media), do: String.starts_with?(media.mime_type || "", "image/")
end

View File

@@ -35,14 +35,28 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
known_refs = MapSet.new(tasks, & &1.ref)
drain_tasks(
remaining, tasks, known_refs, project_id, post_id, language, translate_targets, parent
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
[],
tasks,
_known_refs,
_project_id,
_post_id,
_language,
_translate_targets,
_parent
) do
Enum.each(tasks, fn task -> Task.await(task, :infinity) end)
end
@@ -65,7 +79,12 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
new_task =
Task.async(fn ->
process_single_image(
next_path, project_id, post_id, language, translate_targets, parent
next_path,
project_id,
post_id,
language,
translate_targets,
parent
)
end)
@@ -81,8 +100,14 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
)
else
drain_tasks(
[next_path | rest], tasks, known_refs,
project_id, post_id, language, translate_targets, parent
[next_path | rest],
tasks,
known_refs,
project_id,
post_id,
language,
translate_targets,
parent
)
end
@@ -93,7 +118,12 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
new_task =
Task.async(fn ->
process_single_image(
next_path, project_id, post_id, language, translate_targets, parent
next_path,
project_id,
post_id,
language,
translate_targets,
parent
)
end)
@@ -109,8 +139,14 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
)
else
drain_tasks(
[next_path | rest], tasks, known_refs,
project_id, post_id, language, translate_targets, parent
[next_path | rest],
tasks,
known_refs,
project_id,
post_id,
language,
translate_targets,
parent
)
end
end
@@ -124,16 +160,22 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
end
defp process_single_image(
path, project_id, post_id, language, translate_targets, parent
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, _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

View File

@@ -642,7 +642,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
defp maybe_update_tab_meta(socket, name) do
title = name || dgettext("ui", "Untitled Import")
Notify.tab_meta(:import, socket.assigns.definition_id, title,
Notify.tab_meta(
:import,
socket.assigns.definition_id,
title,
dgettext(
"ui",
"Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported."

View File

@@ -126,8 +126,12 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
Notify.dirty(:media, media.id, false)
Notify.tab_meta(:media, media.id, display_title(updated_media),
updated_media.original_name || updated_media.mime_type || "")
Notify.tab_meta(
:media,
media.id,
display_title(updated_media),
updated_media.original_name || updated_media.mime_type || ""
)
{:noreply, socket}
@@ -484,8 +488,12 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
Notify.dirty(:media, media.id, false)
Notify.tab_meta(:media, media.id, display_title(updated_media),
updated_media.original_name || updated_media.mime_type || "")
Notify.tab_meta(
:media,
media.id,
display_title(updated_media),
updated_media.original_name || updated_media.mime_type || ""
)
notify_output(socket, dgettext("ui", "Media"), dgettext("ui", "Media saved"))
socket

View File

@@ -180,6 +180,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end
@spec move_selected(term(), term()) :: term()
def move_selected(%{selected_id: @home_item_id} = state, _direction), do: state
def move_selected(%{selected_id: selected_id} = state, direction)
when direction in [:up, :down] do
case find_path(state.items, selected_id) do
@@ -209,6 +211,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end
@spec indent_selected(term()) :: term()
def indent_selected(%{selected_id: @home_item_id} = state), do: state
def indent_selected(%{selected_id: selected_id} = state) do
case find_path(state.items, selected_id) do
nil ->
@@ -249,6 +253,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end
@spec unindent_selected(term()) :: term()
def unindent_selected(%{selected_id: @home_item_id} = state), do: state
def unindent_selected(%{selected_id: selected_id} = state) do
case find_path(state.items, selected_id) do
nil ->
@@ -295,6 +301,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
when drag_item_id == target_item_id,
do: state
def drop_selected(state, @home_item_id, _target_item_id, _position), do: state
@spec drop_selected(term(), term(), term(), term()) :: term()
def drop_selected(state, drag_item_id, target_item_id, position) do
drag_path = find_path(state.items, drag_item_id)

View File

@@ -5,57 +5,73 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
@spec can_move_up?(term(), term()) :: term()
def can_move_up?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do
[_parent, index] -> index > 0
[index] -> index > 0
path when is_list(path) -> List.last(path) > 0
_other -> false
if selected_id == TreeOps.home_item_id() do
false
else
case TreeOps.find_path(items, selected_id) do
[_parent, index] -> index > 0
[index] -> index > 0
path when is_list(path) -> List.last(path) > 0
_other -> false
end
end
end
@spec can_move_down?(term(), term()) :: term()
def can_move_down?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do
nil ->
false
if selected_id == TreeOps.home_item_id() do
false
else
case TreeOps.find_path(items, selected_id) do
nil ->
false
path ->
parent_path = Enum.drop(path, -1)
index = List.last(path)
index < length(TreeOps.items_at_path(items, parent_path)) - 1
path ->
parent_path = Enum.drop(path, -1)
index = List.last(path)
index < length(TreeOps.items_at_path(items, parent_path)) - 1
end
end
end
@spec can_indent?(term(), term()) :: term()
def can_indent?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do
nil ->
false
if selected_id == TreeOps.home_item_id() do
false
else
case TreeOps.find_path(items, selected_id) do
nil ->
false
[] ->
false
[] ->
false
[_index] = path ->
index = List.last(path)
index > 0 and match?(%{kind: :submenu}, TreeOps.item_at_path(items, [index - 1]))
[_index] = path ->
index = List.last(path)
index > 0 and match?(%{kind: :submenu}, TreeOps.item_at_path(items, [index - 1]))
path ->
index = List.last(path)
path ->
index = List.last(path)
index > 0 and
match?(
%{kind: :submenu},
TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1])
)
index > 0 and
match?(
%{kind: :submenu},
TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1])
)
end
end
end
@spec can_unindent?(term(), term()) :: term()
def can_unindent?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do
[_index] -> false
path when is_list(path) -> length(path) > 1
_other -> false
if selected_id == TreeOps.home_item_id() do
false
else
case TreeOps.find_path(items, selected_id) do
[_index] -> false
path when is_list(path) -> length(path) > 1
_other -> false
end
end
end

View File

@@ -3,9 +3,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
use Phoenix.LiveComponent
alias BDS.{AI, Posts, Preview}
alias BDS.{AI, Metadata, Posts, Preview}
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.Notify
alias BDS.Desktop.ShellLive.{EditorImageDrop, Notify}
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata}
alias BDS.Posts.Post
alias BDS.Tags
@@ -212,6 +212,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
{:noreply, do_archive(socket)}
end
def handle_event("editor_image_dropped", %{"path" => path}, socket)
when is_binary(path) do
{:noreply, do_image_drop(socket, path)}
end
def handle_event("unarchive_post_editor", _params, socket) do
{:noreply, do_unarchive(socket)}
end
@@ -471,8 +476,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|> assign(:dirty?, false)
|> build_data()
Notify.tab_meta(:post, post.id, record_title(record, refreshed_post),
Atom.to_string(record_status(record)))
Notify.tab_meta(
:post,
post.id,
record_title(record, refreshed_post),
Atom.to_string(record_status(record))
)
Notify.dirty(:post, post.id, false)
Notify.cancel_auto_save(:post, post.id)
@@ -511,8 +520,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|> assign(:dirty?, false)
|> build_data()
Notify.tab_meta(:post, post.id, record_title(record, refreshed_post),
Atom.to_string(record_status(record)))
Notify.tab_meta(
:post,
post.id,
record_title(record, refreshed_post),
Atom.to_string(record_status(record))
)
Notify.dirty(:post, post.id, false)
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post published"))
@@ -546,9 +559,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|> assign(:dirty?, false)
|> build_data()
Notify.tab_meta(:post, post.id,
Notify.tab_meta(
:post,
post.id,
restored_post.title || restored_post.slug || restored_post.id,
Atom.to_string(restored_post.status || :draft))
Atom.to_string(restored_post.status || :draft)
)
Notify.dirty(:post, post.id, false)
socket
@@ -607,6 +623,56 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
# Drag-and-drop image chain (action_patterns.allium DragDropImageChain).
# Steps 1-4 run synchronously while the user waits; steps 5-6 (AI analysis +
# auto-translate) run in the background and are gated behind airplane mode.
defp do_image_drop(socket, path) do
case socket.assigns.post do
%Post{} = post ->
case EditorImageDrop.import_and_link(post.project_id, post.id, path) do
{:ok, media, markdown} ->
maybe_enrich_dropped_image(media, post)
socket
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{
id: socket.assigns.post_id,
content: markdown
})
|> notify_output(
dgettext("ui", "Insert Image"),
dgettext("ui", "Added %{name}", name: media.original_name)
)
{:error, reason} ->
notify_output(
socket,
dgettext("ui", "Insert Image"),
dgettext("ui", "Failed to import %{path}: %{reason}",
path: Path.basename(path),
reason: inspect(reason)
),
"error"
)
end
_other ->
socket
end
end
defp maybe_enrich_dropped_image(media, post) do
unless AI.airplane_mode?() do
{:ok, metadata} = Metadata.get_project_metadata(post.project_id)
language = metadata.main_language || "en"
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
EditorImageDrop.enrich(media, post.id, language)
end)
end
:ok
end
defp do_unarchive(socket) do
case socket.assigns.post do
nil ->

View File

@@ -436,11 +436,14 @@
class="post-editor-markdown-surface monaco-editor-shell"
data-testid="post-editor-markdown-surface"
phx-hook="MonacoEditor"
phx-target={@myself}
data-monaco-editor-id={@post_editor.id}
data-monaco-input-id={"post-editor-content-#{@post_editor.id}"}
data-monaco-language="markdown-with-macros"
data-monaco-word-wrap="on"
data-monaco-insert-event="post-editor-insert-content"
data-monaco-drop-event="editor_image_dropped"
data-monaco-drop-post-id={@post_editor.id}
>
<div id={"post-editor-monaco-#{@post_editor.id}"} class="monaco-editor-instance min-h-0 flex-1" phx-update="ignore"></div>
<textarea id={"post-editor-content-#{@post_editor.id}"} class="monaco-editor-input post-editor-content" data-testid="post-editor-content" data-post-editor-id={@post_editor.id} name="post_editor[content]" rows="18" spellcheck="false"><%= @post_editor.form["content"] %></textarea>

View File

@@ -271,10 +271,10 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
end
attr :color, :string, default: nil
attr :presets, :list, required: true
attr :pick_event, :string, required: true
attr :target, :any, required: true
attr(:color, :string, default: nil)
attr(:presets, :list, required: true)
attr(:pick_event, :string, required: true)
attr(:target, :any, required: true)
defp colour_picker(assigns) do
~H"""

View File

@@ -584,7 +584,9 @@ defmodule BDS.Embeddings do
end
defp duplicate_pairs_with_rebuild(project_id, entries, on_progress) do
case Index.duplicate_pairs(project_id, entries, @duplicate_threshold, on_progress: on_progress) do
case Index.duplicate_pairs(project_id, entries, @duplicate_threshold,
on_progress: on_progress
) do
{:ok, pairs} ->
{:ok, pairs}

View File

@@ -155,7 +155,9 @@ defmodule BDS.Embeddings.Backends.Neural do
# Place model params/tensors on the Apple GPU (Metal) when accelerating with
# EMLX so the compiled inference pass actually runs on-device. EXLA manages
# its own device placement, so nothing to do there.
defp maybe_set_default_backend(:emlx), do: Nx.global_default_backend({EMLX.Backend, device: :gpu})
defp maybe_set_default_backend(:emlx),
do: Nx.global_default_backend({EMLX.Backend, device: :gpu})
defp maybe_set_default_backend(:exla), do: :ok
@doc false

View File

@@ -58,7 +58,11 @@ defmodule BDS.Embeddings.Index do
"""
def neighbors(project_id, query_label, query_vector, limit)
when is_binary(project_id) and is_integer(query_label) and is_binary(query_vector) do
GenServer.call(__MODULE__, {:neighbors, project_id, query_label, query_vector, limit}, :infinity)
GenServer.call(
__MODULE__,
{:neighbors, project_id, query_label, query_vector, limit},
:infinity
)
end
@doc """
@@ -99,6 +103,10 @@ defmodule BDS.Embeddings.Index do
@impl true
def handle_call({:put, project_id, dimensions, entries}, _from, state) do
# Cancel any pending debounce for this project first: build_entry/2 returns a
# fresh entry with timer: nil, so without this the previous timer would be
# orphaned (left to fire a redundant save) instead of coalescing.
state = cancel_pending_save(state, project_id)
entry = build_entry(dimensions, entries)
state = state |> Map.put(project_id, entry) |> schedule_save(project_id)
{:reply, :ok, state}
@@ -167,7 +175,10 @@ defmodule BDS.Embeddings.Index do
defp build_entry(dimensions, entries) do
count = length(entries)
{:ok, index} = HNSWLib.Index.new(@space, dimensions, count, m: @m, ef_construction: @ef_construction)
{:ok, index} =
HNSWLib.Index.new(@space, dimensions, count, m: @m, ef_construction: @ef_construction)
:ok = HNSWLib.Index.set_ef(index, @ef_search)
tensor =
@@ -258,6 +269,17 @@ defmodule BDS.Embeddings.Index do
Map.put(state, project_id, %{entry | timer: timer})
end
defp cancel_pending_save(state, project_id) do
case Map.get(state, project_id) do
%{timer: timer} = entry when is_reference(timer) ->
Process.cancel_timer(timer)
Map.put(state, project_id, %{entry | timer: nil})
_other ->
state
end
end
defp save_now(state, project_id) do
case Map.get(state, project_id) do
nil ->

View File

@@ -82,9 +82,7 @@ defmodule BDS.Generation.Pagefind do
# Returns nil when the page is not marked, so unmarked pages are excluded
# from the index entirely (matching Pagefind semantics).
defp body_text(content) do
case Regex.run(~r/<([a-zA-Z0-9]+)[^>]*\bdata-pagefind-body\b[^>]*>/, content,
return: :index
) do
case Regex.run(~r/<([a-zA-Z0-9]+)[^>]*\bdata-pagefind-body\b[^>]*>/, content, return: :index) do
[{open_start, open_len}, {tag_start, tag_len}] ->
tag = binary_part(content, tag_start, tag_len)
region = scoped_region(content, tag, open_start + open_len)
@@ -117,7 +115,10 @@ defmodule BDS.Generation.Pagefind do
end
defp event_kind(rest, pos, tag) do
if String.starts_with?(binary_part(rest, pos, min(2 + byte_size(tag), byte_size(rest) - pos)), "</") do
if String.starts_with?(
binary_part(rest, pos, min(2 + byte_size(tag), byte_size(rest) - pos)),
"</"
) do
:close
else
:open

View File

@@ -240,7 +240,11 @@ defmodule BDS.Media do
|> Repo.insert_or_update!()
end) do
{:ok, updated_translation} ->
log_sidecar_error(write_translation_sidecar(project, media, updated_translation), media.id)
log_sidecar_error(
write_translation_sidecar(project, media, updated_translation),
media.id
)
:ok = Search.sync_media(media.id)
{:ok, updated_translation}

View File

@@ -178,7 +178,9 @@ defmodule BDS.Preview do
case kind do
:media ->
serve_file(safe_join(server.data_dir, Path.join(["media", relative_path])),
server: server, query_params: query_params)
server: server,
query_params: query_params
)
:generated ->
case BDS.Preview.Router.render_route(server.project_id, request_path) do
@@ -187,7 +189,9 @@ defmodule BDS.Preview do
:not_matched ->
serve_file(safe_join(Path.join(server.data_dir, "html"), relative_path),
server: server, query_params: query_params)
server: server,
query_params: query_params
)
end
end
end
@@ -240,7 +244,10 @@ defmodule BDS.Preview do
defp draft_preview_payload(post, query_params) do
requested_language = query_params |> Map.get("lang") |> normalize_requested_language()
effective_slug = post.template_slug || TemplateSelection.resolve_post_template_slug(post.project_id, post.tags, post.categories)
effective_slug =
post.template_slug ||
TemplateSelection.resolve_post_template_slug(post.project_id, post.tags, post.categories)
case draft_preview_translation(post.id, requested_language, post.language) do
%Translation{} = translation ->

View File

@@ -132,6 +132,7 @@ defmodule BDS.Preview.Router do
defp render(project_id, {:home, page_number}, language, main_language, metadata) do
posts = load_published_list_posts(project_id, metadata)
posts = maybe_resolve_language(posts, language, main_language, project_id)
render_list(project_id, posts, page_number, metadata, language, main_language, %{kind: "core"})
end
@@ -193,7 +194,13 @@ defmodule BDS.Preview.Router do
})
end
defp render(project_id, {:day, year, month, day, page_number}, language, main_language, metadata) do
defp render(
project_id,
{:day, year, month, day, page_number},
language,
main_language,
metadata
) do
posts = load_published_posts_by_day(project_id, year, month, day)
posts = maybe_resolve_language(posts, language, main_language, project_id)
@@ -208,7 +215,8 @@ defmodule BDS.Preview.Router do
## Post rendering
defp render_post(project_id, post, language, main_language) do
{effective_record, body} = resolve_post_for_language(project_id, post, language, main_language)
{effective_record, body} =
resolve_post_for_language(project_id, post, language, main_language)
assigns = %{
id: effective_record.id,
@@ -220,7 +228,9 @@ defmodule BDS.Preview.Router do
_post_record: effective_record
}
effective_slug = post.template_slug || TemplateSelection.resolve_post_template_slug(project_id, post.tags, post.categories)
effective_slug =
post.template_slug ||
TemplateSelection.resolve_post_template_slug(project_id, post.tags, post.categories)
case Rendering.render_post_page(project_id, effective_slug, assigns) do
{:ok, rendered} -> {:ok, rendered}
@@ -370,7 +380,8 @@ defmodule BDS.Preview.Router do
defp archive_page_title(%{kind: "date", year: y, month: m, day: d})
when is_integer(y) and is_integer(m) and is_integer(d),
do: "#{y}-#{String.pad_leading(Integer.to_string(m), 2, "0")}-#{String.pad_leading(Integer.to_string(d), 2, "0")}"
do:
"#{y}-#{String.pad_leading(Integer.to_string(m), 2, "0")}-#{String.pad_leading(Integer.to_string(d), 2, "0")}"
defp archive_page_title(%{kind: "date", year: y, month: m})
when is_integer(y) and is_integer(m),
@@ -504,7 +515,8 @@ defmodule BDS.Preview.Router do
if String.downcase(to_string(language)) == String.downcase(to_string(main_language)) do
posts
else
translations = load_translations_for_language(project_id, Enum.map(posts, & &1.id), language)
translations =
load_translations_for_language(project_id, Enum.map(posts, & &1.id), language)
Enum.map(posts, fn post ->
case Map.get(translations, post.id) do

View File

@@ -104,8 +104,9 @@ defmodule BDS.Projects do
end
@spec project_data_dir(Project.t()) :: String.t()
def project_data_dir(%Project{data_path: data_path}) when is_binary(data_path) and data_path != "",
do: data_path
def project_data_dir(%Project{data_path: data_path})
when is_binary(data_path) and data_path != "",
do: data_path
# A project without an explicit data_path resolves to its folder under the
# per-user default content location — never priv/data inside the repo

View File

@@ -43,12 +43,26 @@ defmodule BDS.Rendering.Filters do
_language_prefix,
context
) do
render_markdown(value, canonical_post_paths, canonical_media_paths, language, context, post_id)
render_markdown(
value,
canonical_post_paths,
canonical_media_paths,
language,
context,
post_id
)
end
@spec render_markdown(term(), map() | nil, map() | nil, String.t(), Liquex.Context.t(), term()) ::
String.t()
def render_markdown(value, canonical_post_paths, canonical_media_paths, language, context, post_id \\ nil) do
def render_markdown(
value,
canonical_post_paths,
canonical_media_paths,
language,
context,
post_id \\ nil
) do
value
|> to_string()
|> replace_built_in_macros(language, context, post_id)
@@ -161,7 +175,11 @@ defmodule BDS.Rendering.Filters do
else
{:error, reason, line} ->
require Logger
Logger.warning("Macro template parse failed (#{template_path}): #{reason} at line #{line}")
Logger.warning(
"Macro template parse failed (#{template_path}): #{reason} at line #{line}"
)
""
{:error, message} ->
@@ -365,8 +383,7 @@ defmodule BDS.Rendering.Filters do
"root_classes" => "macro-photo-archive",
"data_attrs" => [],
"months" => months,
"empty_label" =>
BDS.Gettext.lgettext(language, "render", "No photos found")
"empty_label" => BDS.Gettext.lgettext(language, "render", "No photos found")
},
context
)
@@ -391,8 +408,7 @@ defmodule BDS.Rendering.Filters do
"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")
"empty_label" => BDS.Gettext.lgettext(language, "render", "No tags")
},
context
)
@@ -456,9 +472,18 @@ defmodule BDS.Rendering.Filters do
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"
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
@@ -555,8 +580,10 @@ defmodule BDS.Rendering.Filters do
_ -> 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
@@ -565,6 +592,7 @@ defmodule BDS.Rendering.Filters do
_ -> nil
end
end
defp parse_integer(value) when is_integer(value), do: value
defp parse_integer(_value), do: nil
@@ -583,6 +611,7 @@ defmodule BDS.Rendering.Filters do
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

View File

@@ -349,5 +349,4 @@ defmodule BDS.Rendering.ListArchive do
do: RenderMetadata.calendar_initial_month(post)
defp calendar_initial_month_from_posts([]), do: nil
end

View File

@@ -63,8 +63,12 @@ defmodule BDS.Scripting do
args,
Keyword.put(opts, :timeout, timeout)
) do
{:ok, nil} -> {:ok, ""}
{:ok, value} -> {:ok, to_string(value)}
{:ok, nil} ->
{:ok, ""}
{:ok, value} ->
{:ok, to_string(value)}
{:error, reason} ->
Logger.warning("execute_macro failed for project #{project_id}: #{inspect(reason)}")
{:error, reason}

View File

@@ -111,8 +111,7 @@ defmodule BDS.Scripting.Capabilities do
get_by_status: one_arg(fn status -> posts_by_status(project_id, status) end),
get_by_year_month: zero_or_one_arg(fn _args -> post_counts_by_year_month(project_id) end),
get_dashboard_stats: zero_or_one_arg(fn _args -> post_dashboard_stats(project_id) end),
get_linked_by:
one_arg(fn post_id -> linked_posts_for(project_id, post_id, :incoming) end),
get_linked_by: one_arg(fn post_id -> linked_posts_for(project_id, post_id, :incoming) end),
get_links_to: one_arg(fn post_id -> linked_posts_for(project_id, post_id, :outgoing) end),
get_preview_url:
two_arg(fn post_id, options -> preview_url(project_id, post_id, options) end),
@@ -157,8 +156,7 @@ defmodule BDS.Scripting.Capabilities do
end),
filter: one_arg(fn filters -> filter_media(project_id, filters) end),
import: one_arg(fn attrs -> import_media(project_id, attrs) end),
get_by_year_month:
zero_or_one_arg(fn _args -> media_counts_by_year_month(project_id) end),
get_by_year_month: zero_or_one_arg(fn _args -> media_counts_by_year_month(project_id) end),
get_file_path: one_arg(fn media_id -> media_file_path(project_id, media_id) end),
update: two_arg(fn media_id, attrs -> update_media(project_id, media_id, attrs) end),
delete: one_arg(fn media_id -> delete_media(project_id, media_id) end),
@@ -172,8 +170,7 @@ defmodule BDS.Scripting.Capabilities do
two_arg(fn media_id, language ->
load_media_translation(project_id, media_id, language)
end),
get_translations:
one_arg(fn media_id -> list_media_translations(project_id, media_id) end),
get_translations: one_arg(fn media_id -> list_media_translations(project_id, media_id) end),
get_url: one_arg(fn media_id -> media_url(project_id, media_id) end),
rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_media_from_files(project_id) end),
regenerate_missing_thumbnails:
@@ -201,8 +198,7 @@ defmodule BDS.Scripting.Capabilities do
get: one_arg(fn script_id -> load_script(project_id, script_id) end),
get_all: zero_or_one_arg(fn _args -> list_scripts(project_id) end),
publish: one_arg(fn script_id -> publish_script(project_id, script_id) end),
rebuild_from_files:
zero_or_one_arg(fn _args -> rebuild_scripts_from_files(project_id) end)
rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_scripts_from_files(project_id) end)
}
end
@@ -295,8 +291,7 @@ defmodule BDS.Scripting.Capabilities do
find_similar: two_arg(fn post_id, limit -> find_similar(post_id, limit) end),
compute_similarities:
two_arg(fn post_id, target_ids -> compute_similarities(post_id, target_ids) end),
suggest_tags:
two_arg(fn post_id, exclude_tags -> suggest_tags(post_id, exclude_tags) end),
suggest_tags: two_arg(fn post_id, exclude_tags -> suggest_tags(post_id, exclude_tags) end),
find_duplicates: zero_or_one_arg(fn _args -> find_duplicates(project_id) end),
dismiss_pair: two_arg(fn post_id_a, post_id_b -> dismiss_pair(post_id_a, post_id_b) end),
index_unindexed_posts: zero_or_one_arg(fn _args -> index_unindexed_posts(project_id) end)

View File

@@ -284,14 +284,24 @@ defmodule BDS.Search do
defp maybe_where_year(query, year) do
year_str = to_string(year)
where(query, [p], fragment("strftime('%Y', datetime(? / 1000, 'unixepoch')) = ?", p.created_at, ^year_str))
where(
query,
[p],
fragment("strftime('%Y', datetime(? / 1000, 'unixepoch')) = ?", p.created_at, ^year_str)
)
end
defp maybe_where_month(query, nil), do: query
defp maybe_where_month(query, month) do
month_str = String.pad_leading(to_string(month), 2, "0")
where(query, [p], fragment("strftime('%m', datetime(? / 1000, 'unixepoch')) = ?", p.created_at, ^month_str))
where(
query,
[p],
fragment("strftime('%m', datetime(? / 1000, 'unixepoch')) = ?", p.created_at, ^month_str)
)
end
defp maybe_where_from(query, nil), do: query
@@ -305,7 +315,10 @@ defmodule BDS.Search do
defp maybe_where_tags(query, tags) do
tags_clause =
Enum.reduce(tags, false, fn tag, acc ->
dynamic([p], ^acc or fragment("EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)", p.tags, ^tag))
dynamic(
[p],
^acc or fragment("EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)", p.tags, ^tag)
)
end)
where(query, [p], ^tags_clause)
@@ -316,7 +329,10 @@ defmodule BDS.Search do
defp maybe_where_tags_media(query, tags) do
tags_clause =
Enum.reduce(tags, false, fn tag, acc ->
dynamic([m], ^acc or fragment("EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)", m.tags, ^tag))
dynamic(
[m],
^acc or fragment("EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)", m.tags, ^tag)
)
end)
where(query, [m], ^tags_clause)
@@ -327,7 +343,15 @@ defmodule BDS.Search do
defp maybe_where_categories(query, categories) do
categories_clause =
Enum.reduce(categories, false, fn category, acc ->
dynamic([p], ^acc or fragment("EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)", p.categories, ^category))
dynamic(
[p],
^acc or
fragment(
"EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)",
p.categories,
^category
)
)
end)
where(query, [p], ^categories_clause)
@@ -548,8 +572,6 @@ defmodule BDS.Search do
)
end
defp post_index_fields(post, translations) do
post_language = normalize_language(post.language)
@@ -656,8 +678,8 @@ defmodule BDS.Search do
else
Repo.all(
from translation in MediaTranslation,
where: translation.translation_for in ^media_ids,
select: {translation.translation_for, translation}
where: translation.translation_for in ^media_ids,
select: {translation.translation_for, translation}
)
|> Enum.group_by(fn {media_id, _} -> media_id end, fn {_, translation} -> translation end)
end

View File

@@ -239,6 +239,7 @@ defmodule BDS.Tags do
{:error, changeset} -> Repo.rollback(changeset)
end
end)
Enum.map(affected_posts, & &1.id)
end)
|> case do

View File

@@ -79,8 +79,14 @@ defmodule BDS.UI.Dashboard do
from post in Post,
where: post.project_id == ^project_id,
group_by: [
fragment("CAST(strftime('%Y', datetime(? / 1000, 'unixepoch')) AS INTEGER)", post.created_at),
fragment("CAST(strftime('%m', datetime(? / 1000, 'unixepoch')) AS INTEGER)", post.created_at)
fragment(
"CAST(strftime('%Y', datetime(? / 1000, 'unixepoch')) AS INTEGER)",
post.created_at
),
fragment(
"CAST(strftime('%m', datetime(? / 1000, 'unixepoch')) AS INTEGER)",
post.created_at
)
],
select: %{
year:

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:589
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archiv"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:589
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:589
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archivo"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:589
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archives"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:589
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archivio"

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,8 @@ msgid ""
msgstr ""
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#: lib/bds/ui/sidebar.ex:288
#: lib/bds/ui/sidebar.ex:589
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -396,8 +396,14 @@ invariant ChatCancellation {
}
invariant StructuredRenderTools {
-- Chat may emit structured render payloads for charts, tables, and forms.
-- Chat may emit structured render payloads via tool calls prefixed "render_".
-- 9 inline surface types: card, chart, form, list, metric, mindmap, table, tabs, text, json.
-- Render tool names: render_card, render_chart, render_form, render_list,
-- render_metric, render_mindmap, render_table, render_tabs.
-- Unrecognized render tool names render as JSON (raw dump).
-- Tab content may nest any surface type including plain text.
-- These payloads are data contracts, not arbitrary HTML strings.
-- Surface data contracts are defined in editor_chat.allium InlineSurface value.
}
invariant BlogStatsPromptAugmentation {

View File

@@ -23,9 +23,72 @@ value ChatMessage {
role: String -- user | assistant | system
content: String -- user: plain text; assistant: GFM markdown
tool_markers: List<ToolMarker>
inline_surfaces: List<InlineSurface>
is_streaming: Boolean -- true while accumulating
}
value InlineSurface {
id: String
type: "card" | "chart" | "form" | "list" | "metric" | "mindmap" | "table" | "tabs" | "text" | "json"
title: String?
subtitle: String? -- card only
body: String? -- card, text
actions: List<SurfaceAction> -- card only
columns: List<String> -- table
rows: List<List<String>> -- table
chart_type: String? -- chart: bar/pie/line
series: List<ChartSeries> -- chart
max_value: Integer? -- chart
label: String? -- metric
value: String? -- metric
items: List<String> -- list
nodes: List<MindmapNode> -- mindmap
fields: List<FormField> -- form
submit_label: String? -- form
submit_action: String? -- form
tabs: List<TabPanel> -- tabs
selected_index: Integer? -- tabs
raw: Map? -- json catch-all
}
value SurfaceAction {
label: String
action: String
payload: Map
}
value ChartSeries {
label: String
value: Integer
segments: List
}
value MindmapNode {
id: String?
label: String
children: List<String>
}
value FormField {
key: String
label: String
input_type: String
placeholder: String?
value: String?
options: List<FieldOption>
required: Boolean
}
value FieldOption {
label: String
value: String
}
value TabPanel {
label: String
content: List<InlineSurface> -- nested surfaces or text
}
value ToolMarker {
tool_name: String
args_preview: String -- string args truncated to config.chat_tool_args_max_length
@@ -66,6 +129,7 @@ value ModelEntry {
config {
chat_tool_args_max_length: Integer = 30
chat_input_max_height: Integer = 200
surface_form_debounce_ms: Integer = 500
}
surface ChatPanelSurface {
@@ -85,6 +149,9 @@ surface ChatPanelSurface {
tm.tool_name
tm.args_preview
tm.is_complete
for sfc in msg.inline_surfaces:
sfc.type
sfc.id
provides:
ChatSendMessage(panel.conversation_id, panel.input_text)
@@ -138,6 +205,19 @@ surface ChatPanelSurface {
-- Actions dispatched through store, same as user clicks.
-- Navigation actions open tabs with pin intent.
@guarantee InlineSurfaceRendering
-- Assistant render-tool calls produce structured inline surfaces
-- rendered between message content and tool markers.
-- 9 surface types: card, chart, form, list, metric, mindmap, table, tabs, text, json.
-- Render tool names: render_card, render_chart, render_form, render_list,
-- render_metric, render_mindmap, render_table, render_tabs.
-- Unrecognized render names render as json (raw dump).
-- Tab content nests any surface type including plain text.
-- Form surfaces persist field values in conversation surface_state.
-- Tab surfaces track selected_index in conversation surface_state.
-- Surfaces can be dismissed, persisted in conversation surface_state.
-- Form debounce: config.surface_form_debounce_ms.
@guarantee TokenTracking
-- Token usage tracked per conversation.
-- Displayed in status bar, not in the chat panel itself.

View File

@@ -16,8 +16,10 @@ value SettingsView {
editor_section: SettingsEditorSection?
categories: List<SettingsCategoryRow>
ai_section: SettingsAISection?
technology_section: SettingsTechnologySection?
publishing_section: SettingsPublishingSection?
mcp_section: SettingsMCPSection?
data_section: SettingsDataSection?
}
value SettingsProjectSection {
@@ -62,6 +64,12 @@ value SettingsAISection {
system_prompt: String -- textarea (12 rows) + Save + Reset to Default
}
value SettingsTechnologySection {
semantic_similarity_enabled: Boolean -- checkbox: enable duplicate search + related-post embeddings
-- Scripting runtime is fixed at the application layer; no runtime switch is exposed.
-- Saved together with project metadata (settings_project form).
}
value SettingsPublishingSection {
ssh_mode: String -- select: scp | rsync
ssh_host: String -- text input
@@ -70,13 +78,19 @@ value SettingsPublishingSection {
}
value SettingsMCPSection {
status: String -- port number or "Not running"
agents: List<MCPAgentRow>
}
value MCPAgentRow {
agent_name: String -- Claude Code, Claude Desktop, GitHub Copilot, etc.
is_installed: Boolean -- Add/Remove toggle
is_supported: Boolean -- false agents show a disabled button + "not supported yet" note
is_installed: Boolean -- toggle label: Remove when installed, else Add
config_path: String? -- agent config file path (nil when unsupported)
}
value SettingsDataSection {
-- Action-only section: rebuild buttons + Open Data Folder. No persisted fields.
rebuild_targets: List<String> -- posts | media | scripts | templates | links | thumbnails | embedding
}
invariant SettingsProtectedCategories {
@@ -88,6 +102,8 @@ invariant SettingsMCPAgents {
-- MCP section has exactly 7 agent rows in this order:
-- Claude Code, Claude Desktop, GitHub Copilot, Gemini CLI,
-- OpenCode, Mistral Vibe, OpenAI Codex.
-- Only Claude Code and GitHub Copilot are supported; the rest render a
-- disabled toggle and a "not supported in the rewrite yet" note.
}
config {
@@ -120,9 +136,11 @@ surface SettingsViewSurface {
SettingsAISystemPromptReset()
SettingsPublishingSaved(publishing_data)
SettingsPublishingCleared()
SettingsTechnologySaved(technology_data)
SettingsMCPAgentToggled(agent_name)
SettingsRebuildRequested(entity_type)
SettingsRegenerateThumbnailsRequested()
SettingsEmbeddingIndexRebuildRequested()
SettingsOpenDataFolderRequested()
StyleThemeSelected(theme_name)
StyleApplyRequested(theme_name)
@@ -174,22 +192,27 @@ surface SettingsViewSurface {
-- Per-model info: max output tokens, context window (when available).
@guarantee TechnologySection
-- Section 5: scripting capabilities are configured at the application level;
-- this section does not expose implementation-specific runtime choices.
-- Semantic Similarity toggle.
-- Section 5: Semantic Similarity toggle
-- ("Enable duplicate search and related-post embeddings").
-- Scripting Runtime row is read-only descriptive text: scripting capabilities
-- are configured at the application layer and expose no runtime switch here.
-- Save button persists with project metadata (settings_project form).
@guarantee PublishingSection
-- Section 6: SSH Mode (scp/rsync), Host, Username, Remote Path.
-- Save + Clear buttons.
@guarantee MCPSection
-- Section 7: Status badge (port or "Not running").
-- 7 agent rows with Add/Remove toggle each.
-- Section 7: 7 agent rows, each with the agent label, its config-file path
-- as a subtitle (or "not supported yet" note), and a toggle button.
-- Supported agents (Claude Code, GitHub Copilot) toggle Add/Remove,
-- writing/removing the bDS server entry in the agent config file.
-- Unsupported agents render a disabled button.
@guarantee DataMaintenanceSection
-- Section 8: 6 rebuild buttons (Posts from Files, Media from Files,
-- Section 8: 7 rebuild buttons (Posts from Files, Media from Files,
-- Scripts from Files, Templates from Files, Links,
-- Regenerate Missing Thumbnails).
-- Regenerate Missing Thumbnails, Rebuild Embedding Index).
-- Open Data Folder button.
-- Each rebuild executes immediately (no confirmation).
-- Runs as background task with progress in Tasks panel.
@@ -203,7 +226,7 @@ surface SettingsViewSurface {
rule SettingsRebuild {
when: SettingsRebuildRequested(entity_type)
-- entity_type: posts | media | scripts | templates | links | thumbnails
-- entity_type: posts | media | scripts | templates | links | thumbnails | embedding
-- Executes immediately (no confirmation dialog)
-- Runs as background task with progress visible in Tasks panel
-- On completion: wholesale replaces store data for that entity type
@@ -212,10 +235,16 @@ rule SettingsRebuild {
-- ─── Style view ───────────────────────────────────────────────
-- The Style view is its OWN singleton tab (tab type `style`, see tabs.allium),
-- NOT one of the SettingsView collapsible sections. It is opened from the
-- Style menu/tab entry and renders the Pico CSS theme editor, distinct from
-- the `settings` tab. Shares the settings_editor code group only because both
-- persist into project metadata.
value StyleView {
themes: List<StyleTheme>
selected_theme: String?
applied_theme: String?
themes: List<StyleTheme> -- 20 named Pico themes (see metadata.supported_pico_themes)
selected_theme: String? -- local selection, defaults to applied_theme
applied_theme: String? -- persisted picoTheme (default when none set)
preview_mode: String -- auto | light | dark
}
@@ -245,9 +274,14 @@ surface StyleViewSurface {
when style.selected_theme != style.applied_theme
StylePreviewModeChanged(mode)
@guarantee SeparateTab
-- Rendered in its own `style` singleton tab (tabs.allium), never inline
-- in the settings view. Requires an active project; no project => no view.
@guarantee ThemePicker
-- Grid of theme buttons (one per Pico CSS theme).
-- Each button: swatch with 3 colour tones (accent, light bg, dark bg) + theme name.
-- Theme name shown via display transform: "-" -> " ", first letter capitalised.
-- Selected theme highlighted with aria-pressed.
@guarantee ControlsRow

View File

@@ -239,6 +239,8 @@ entity ChatMessage {
content: String?
tool_call_id: String? -- For tool responses
tool_calls: String? -- JSON array of tool calls
cache_read_tokens: Integer?
cache_write_tokens: Integer?
created_at: Timestamp
}
@@ -522,6 +524,8 @@ surface ChatMessageRecordSurface {
message.content when message.content != null
message.tool_call_id when message.tool_call_id != null
message.tool_calls when message.tool_calls != null
message.cache_read_tokens when message.cache_read_tokens != null
message.cache_write_tokens when message.cache_write_tokens != null
message.created_at
}

View File

@@ -186,3 +186,128 @@ invariant FtsIncludesTranslations {
for t in post.translations:
includes_text(search_index(post), t.title)
}
-- ===========================================================================
-- Auto-Translation System
-- Distilled from: lib/bds/posts/auto_translation.ex
--
-- Two entry points share one translation primitive:
-- 1. ScheduleAutoTranslation - reactive, fired after a post is created or
-- updated. One background task per missing language produces a DRAFT
-- translation, then cascades to the post's linked media.
-- 2. FillMissingTranslations - batch maintenance action. Scans every
-- published post, AUTO-PUBLISHES the generated translations, fills linked
-- media, reports progress and returns a summary.
-- All AI work is gated by a resolvable endpoint and runs on background tasks.
-- ===========================================================================
config {
-- Background translations use the "AI" task group named per project.
auto_translation_task_group_name: String = "AI"
}
surface AutoTranslationControlSurface {
facing _: TranslationOperator
provides:
-- Reactive trigger: emitted by post create/update side effects.
PostSavedForAutoTranslation(post)
-- Batch trigger: "fill missing translations" maintenance action.
FillMissingTranslationsRequested(project)
}
invariant AutoTranslationGatedByEndpoint {
-- No automatic translation runs unless an endpoint is resolvable for the
-- current mode. Airplane mode needs url+model; online additionally needs an
-- api_key. When unconfigured, scheduling is a silent no-op.
-- See ai.allium AirplaneModeGating for endpoint selection.
for post in Posts:
auto_translation_runs(post) implies endpoint_configured(post.project)
}
invariant AutoTranslationSkipsDoNotTranslate {
-- Posts flagged do_not_translate never schedule background translation,
-- and the batch scan rejects them before computing missing languages.
for post in Posts where post.do_not_translate:
not auto_translation_runs(post)
}
invariant AutoTranslationOnlyMissingLanguages {
-- The target set is the configured languages (main_language plus
-- blog_languages, normalized + de-duplicated) minus the post's source
-- language and any language that already has a translation.
for post in Posts:
auto_translation_targets(post) =
configured_languages(post.project)
- source_language(post)
- post.available_languages
}
rule ScheduleAutoTranslation {
when: PostSavedForAutoTranslation(post)
requires: not post.do_not_translate
requires: endpoint_configured(post.project)
-- One background task per missing language, each producing a DRAFT
-- translation (not auto-published) followed by a media cascade.
for language in auto_translation_targets(post):
ensures: BackgroundTaskSubmitted(
group: post.project,
group_name: config.auto_translation_task_group_name)
ensures: AutoTranslatePost(post, language, auto_publish: false)
ensures: AutoTranslateMediaCascade(post, language)
@guidance
-- Best-effort: missing metadata, unconfigured endpoint, or
-- do_not_translate all collapse to a silent success with no task.
}
rule AutoTranslatePost {
when: AutoTranslatePost(post, language, auto_publish)
requires: trim(editor_body(post)) != ""
-- Calls the AI endpoint with the post's source language, then upserts a
-- translation marked auto_generated. Publishes only in the batch path.
ensures:
let translation = UpsertPostTranslation(post, language)
if auto_publish:
translation.status = published
else:
translation.status = draft
@guidance
-- An empty body yields a no_content_to_translate error and no
-- translation is created.
}
rule AutoTranslateMediaCascade {
when: AutoTranslateMediaCascade(post, language)
-- After a post translation, each linked media (ordered by sort_order) gets
-- its own background task when its source language differs from the target
-- and it lacks a translation in that language.
for m in post.linked_media:
if m.language != "" and m.language != language and not (language in m.available_languages):
ensures: BackgroundTaskSubmitted(
group: post.project,
group_name: config.auto_translation_task_group_name)
ensures: media/UpsertMediaTranslation(m, language)
}
rule FillMissingTranslations {
when: FillMissingTranslationsRequested(project)
-- Batch maintenance. No-op (nothing_to_do) when there is at most one
-- configured language or nothing is missing. Otherwise scans published,
-- non-do_not_translate posts and their linked media.
requires: configured_languages(project).count > 1
for post in project.posts where status = published and not do_not_translate:
for language in auto_translation_targets(post):
ensures: AutoTranslatePost(post, language, auto_publish: true)
ensures: AutoTranslateMediaCascade(post, language)
ensures: ProgressReported(project)
ensures: FillMissingTranslationsCompleted(project)
@guidance
-- Returns a summary of counts: translated_posts, translated_media,
-- failed_count, warned_count, and nothing_to_do. nothing_to_do is true
-- when there is at most one configured language or nothing is missing.
-- Per-item failures increment failed_count and never abort the batch.
-- Progress runs through scanning (0.0-0.15) then per-item (0.15-1.0).
}

View File

@@ -280,6 +280,32 @@ defmodule BDS.AITest do
end
end
# Always returns another tool call and never a final answer, so a chat would
# loop forever if the round count were not bounded.
defmodule LoopingToolRuntime do
def generate(endpoint, request, opts) do
test_pid = Keyword.fetch!(opts, :test_pid)
send(test_pid, {:looping_request, endpoint, request})
{:ok,
%{
tool_calls: [
%{
id: "call-loop-#{System.unique_integer([:positive])}",
name: "blog_stats",
arguments: %{}
}
],
usage: %{
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0
}
}}
end
end
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
:ok
@@ -1209,6 +1235,160 @@ defmodule BDS.AITest do
end)
end
test "chat request is truncated to the model context window, dropping oldest pairs and keeping the system prompt" do
{:ok, project} = create_project_fixture("Truncation Chat")
_fixtures = seed_project_content(project.id)
# A catalog model with a small context window forces truncation. No tool
# support keeps the round single so the captured request is the chat call.
Repo.insert!(
BDS.AI.CatalogProvider.changeset(%BDS.AI.CatalogProvider{}, %{
id: "test",
name: "Test Provider",
updated_at: Persistence.now_ms()
})
)
Repo.insert!(
BDS.AI.Model.changeset(%BDS.AI.Model{}, %{
provider: "test",
model_id: "tiny-ctx-model",
name: "Tiny Context Model",
supports_tool_calls: false,
context_window: 2_000,
max_input_tokens: 2_000,
max_output_tokens: 256,
updated_at: Persistence.now_ms()
})
)
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
:online,
%{
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "tiny-ctx-model"
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(false)
# Explicit title skips title generation, so only the chat request is sent.
assert {:ok, conversation} =
BDS.AI.start_chat(%{title: "Truncation Test", model: "tiny-ctx-model"})
# Seed a long history of alternating user/assistant turns, each large enough
# that the full history blows past the context budget.
seeded_count = 40
base_time = Persistence.now_ms() - 1_000_000
for n <- 1..seeded_count do
role = if rem(n, 2) == 1, do: :user, else: :assistant
marker = "[[MARK-#{String.pad_leading(Integer.to_string(n), 4, "0")}]]"
Repo.insert!(%BDS.AI.ChatMessage{
conversation_id: conversation.id,
role: role,
content: marker <> " " <> String.duplicate("x", 380),
created_at: base_time + n
})
end
assert {:ok, _reply} =
BDS.AI.send_chat_message(conversation.id, "newest question please answer",
runtime: FakeRuntime,
test_pid: self(),
project_id: project.id,
secret_backend: FakeSecretBackend
)
assert_received {:runtime_request, _endpoint, request}
assert request.operation == :chat
[system_message | rest] = request.messages
# The system prompt is preserved as the first message.
assert system_message["role"] == "system"
assert is_binary(system_message["content"]) and system_message["content"] != ""
# Truncation actually happened: not every seeded turn survives.
refute Enum.any?(rest, &(&1["role"] == "system"))
assert length(rest) < seeded_count + 1
# The newest user turn is always kept (it is the request's last message).
assert List.last(rest)["content"] =~ "newest question please answer"
kept_markers =
rest
|> Enum.flat_map(fn message ->
case Regex.run(~r/\[\[MARK-(\d+)\]\]/, message["content"] || "") do
[_full, number] -> [String.to_integer(number)]
_no_match -> []
end
end)
assert kept_markers != []
# Oldest pairs are dropped first: the surviving markers form a contiguous
# suffix ending at the newest one, and the oldest is gone.
assert Enum.max(kept_markers) == seeded_count
assert Enum.min(kept_markers) > 1
assert kept_markers == Enum.sort(kept_markers)
assert Enum.max(kept_markers) - Enum.min(kept_markers) + 1 == length(kept_markers)
end
test "chat tool execution is bounded by config.chat_max_tool_rounds" do
{:ok, project} = create_project_fixture("Tool Loop Chat")
_fixtures = seed_project_content(project.id)
previous_chat_config = Application.get_env(:bds, :chat, [])
max_rounds = 3
Application.put_env(:bds, :chat, Keyword.put(previous_chat_config, :max_tool_rounds, max_rounds))
on_exit(fn -> Application.put_env(:bds, :chat, previous_chat_config) end)
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
:online,
%{
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(false)
# Explicit title skips title generation, so only chat rounds reach the runtime.
assert {:ok, conversation} =
BDS.AI.start_chat(%{title: "Tool Loop", model: "gpt-4o-mini"})
# The runtime never stops calling tools, so the loop only ends because the
# round budget is exhausted.
assert {:error, %{kind: :tool_loop_exhausted}} =
BDS.AI.send_chat_message(conversation.id, "loop forever please",
runtime: LoopingToolRuntime,
test_pid: self(),
project_id: project.id,
secret_backend: FakeSecretBackend
)
# Exactly max_rounds generate calls happen: the final (rounds_left == 0)
# round short-circuits before contacting the runtime.
request_count = drain_looping_requests(0)
assert request_count == max_rounds
end
defp drain_looping_requests(count) do
receive do
{:looping_request, _endpoint, _request} -> drain_looping_requests(count + 1)
after
0 -> count
end
end
test "chat generates a short title after the first user turn using the title model" do
{:ok, project} = create_project_fixture("Title Chat")
_fixtures = seed_project_content(project.id)

126
test/bds/blogmark_test.exs Normal file
View File

@@ -0,0 +1,126 @@
defmodule BDS.BlogmarkTest do
use ExUnit.Case, async: false
alias BDS.Blogmark
alias BDS.Scripts
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
temp_dir =
Path.join(System.tmp_dir!(), "bds-blogmark-#{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: "Blogmark", data_path: temp_dir})
%{project: project}
end
describe "parse_deep_link/1" do
test "parses a new-post deep link into a candidate" do
url =
"bds2://new-post?title=" <>
URI.encode_www_form("Hello World") <>
"&url=" <> URI.encode_www_form("https://example.com/page")
assert {:ok, candidate} = Blogmark.parse_deep_link(url)
assert candidate["title"] == "Hello World"
assert candidate["url"] == "https://example.com/page"
assert candidate["content"] == nil
assert candidate["tags"] == []
assert candidate["categories"] == []
end
test "parses optional content, tags and categories" do
url =
"bds2://new-post?title=T&url=https://x&content=" <>
URI.encode_www_form("body text") <>
"&tags=" <>
URI.encode_www_form("a, b ,c") <>
"&categories=" <> URI.encode_www_form("news")
assert {:ok, candidate} = Blogmark.parse_deep_link(url)
assert candidate["content"] == "body text"
assert candidate["tags"] == ["a", "b", "c"]
assert candidate["categories"] == ["news"]
end
test "rejects an unsupported scheme" do
assert {:error, :unsupported_scheme} =
Blogmark.parse_deep_link("bds://new-post?title=T")
end
test "rejects an unsupported action" do
assert {:error, :unsupported_action} =
Blogmark.parse_deep_link("bds2://open-thing?id=1")
end
end
describe "receive_deep_link/3" do
test "creates a draft post from the deep link", %{project: project} do
url =
"bds2://new-post?title=" <>
URI.encode_www_form("A Bookmarked Page") <>
"&url=" <> URI.encode_www_form("https://example.com/a")
assert {:ok, %{post: post, toasts: [], errors: []}} =
Blogmark.receive_deep_link(project.id, url)
assert post.title == "A Bookmarked Page"
assert post.status == :draft
assert post.project_id == project.id
end
test "runs enabled transforms on the candidate before creating the post", %{project: project} do
{:ok, _script} =
Scripts.create_script(%{
project_id: project.id,
title: "Prefix",
kind: :transform,
content: """
function main(data, ctx)
data.title = "[" .. ctx.source .. "] " .. data.title
return { data = data, toasts = { "transformed" } }
end
""",
entrypoint: "main"
})
url = "bds2://new-post?title=Page&url=https://x"
assert {:ok, %{post: post, toasts: toasts}} =
Blogmark.receive_deep_link(project.id, url)
assert post.title == "[blogmark] Page"
assert toasts == ["transformed"]
end
test "defaults the category from blogmark_category when none provided", %{project: project} do
{:ok, _meta} =
BDS.Metadata.update_project_metadata(project.id, %{blogmark_category: "aside"})
url = "bds2://new-post?title=Page&url=https://x"
assert {:ok, %{post: post}} = Blogmark.receive_deep_link(project.id, url)
assert post.categories == ["aside"]
end
test "keeps explicit categories over the blogmark default", %{project: project} do
{:ok, _meta} =
BDS.Metadata.update_project_metadata(project.id, %{blogmark_category: "aside"})
url = "bds2://new-post?title=Page&url=https://x&categories=article"
assert {:ok, %{post: post}} = Blogmark.receive_deep_link(project.id, url)
assert post.categories == ["article"]
end
test "returns an error for an invalid deep link", %{project: project} do
assert {:error, :unsupported_scheme} =
Blogmark.receive_deep_link(project.id, "bds://new-post?title=T")
end
end
end

View File

@@ -51,7 +51,10 @@ defmodule BDS.CSM006NPlusOneTest do
end
# Clear FTS and reindex
Repo.query!("DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)", [project.id])
Repo.query!(
"DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)",
[project.id]
)
# Reindex should succeed and produce correct FTS entries
assert :ok = BDS.Search.reindex_posts(project.id)
@@ -82,7 +85,11 @@ defmodule BDS.CSM006NPlusOneTest do
language: "en"
})
Repo.query!("DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)", [project.id])
Repo.query!(
"DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)",
[project.id]
)
assert :ok = BDS.Search.reindex_posts(project.id)
{:ok, results} = BDS.Search.search_posts(project.id, "unique-keyword-xyz")

View File

@@ -24,12 +24,13 @@ defmodule BDS.CSM007ReloadShellTest do
test "triggers no dashboard or git queries", %{project: _project} do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
query_count = count_queries(fn ->
render_click(view, "toggle_sidebar", %{})
end)
query_count =
count_queries(fn ->
render_click(view, "toggle_sidebar", %{})
end)
assert query_count == 0,
"Expected 0 DB queries for sidebar toggle, got #{query_count}"
"Expected 0 DB queries for sidebar toggle, got #{query_count}"
end
end
@@ -37,12 +38,13 @@ defmodule BDS.CSM007ReloadShellTest do
test "triggers no DB queries" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
query_count = count_queries(fn ->
render_click(view, "toggle_panel", %{})
end)
query_count =
count_queries(fn ->
render_click(view, "toggle_panel", %{})
end)
assert query_count == 0,
"Expected 0 DB queries for panel toggle, got #{query_count}"
"Expected 0 DB queries for panel toggle, got #{query_count}"
end
end
@@ -50,16 +52,17 @@ defmodule BDS.CSM007ReloadShellTest do
test "triggers no DB queries" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
query_count = count_queries(fn ->
render_click(view, "sync_layout", %{
"sidebar_width" => "300",
"sidebar_visible" => "true",
"panel_visible" => "false"
})
end)
query_count =
count_queries(fn ->
render_click(view, "sync_layout", %{
"sidebar_width" => "300",
"sidebar_visible" => "true",
"panel_visible" => "false"
})
end)
assert query_count == 0,
"Expected 0 DB queries for sync_layout, got #{query_count}"
"Expected 0 DB queries for sync_layout, got #{query_count}"
end
end
@@ -67,12 +70,13 @@ defmodule BDS.CSM007ReloadShellTest do
test "triggers no DB queries" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
query_count = count_queries(fn ->
render_click(view, "select_panel_tab", %{"tab" => "output"})
end)
query_count =
count_queries(fn ->
render_click(view, "select_panel_tab", %{"tab" => "output"})
end)
assert query_count == 0,
"Expected 0 DB queries for select_panel_tab, got #{query_count}"
"Expected 0 DB queries for select_panel_tab, got #{query_count}"
end
end
@@ -80,14 +84,15 @@ defmodule BDS.CSM007ReloadShellTest do
test "triggers sidebar query but not dashboard or git queries" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
{query_count, query_sources} = count_queries_with_sources(fn ->
render_click(view, "select_view", %{"view" => "media"})
end)
{query_count, query_sources} =
count_queries_with_sources(fn ->
render_click(view, "select_view", %{"view" => "media"})
end)
assert query_count > 0, "Expected at least 1 query for view change"
refute "dashboard" in query_sources or "projects" in query_sources,
"View change should not query dashboard or projects, got: #{inspect(query_sources)}"
"View change should not query dashboard or projects, got: #{inspect(query_sources)}"
end
end
@@ -95,14 +100,15 @@ defmodule BDS.CSM007ReloadShellTest do
test "do not trigger dashboard or git queries" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
{_count, query_sources} = count_queries_with_sources(fn ->
render_click(view, "update_sidebar_search", %{
"sidebar_filters" => %{"search" => "test"}
})
end)
{_count, query_sources} =
count_queries_with_sources(fn ->
render_click(view, "update_sidebar_search", %{
"sidebar_filters" => %{"search" => "test"}
})
end)
refute "dashboard" in query_sources,
"Sidebar search should not query dashboard, got: #{inspect(query_sources)}"
"Sidebar search should not query dashboard, got: #{inspect(query_sources)}"
end
end
@@ -110,12 +116,13 @@ defmodule BDS.CSM007ReloadShellTest do
test "triggers only the settings write, no refresh queries" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
query_count = count_queries(fn ->
render_click(view, "toggle_offline_mode", %{})
end)
query_count =
count_queries(fn ->
render_click(view, "toggle_offline_mode", %{})
end)
assert query_count == 1,
"Expected exactly 1 DB query (settings write) for offline mode toggle, got #{query_count}"
"Expected exactly 1 DB query (settings write) for offline mode toggle, got #{query_count}"
end
end

View File

@@ -37,12 +37,13 @@ defmodule BDS.CSM008RenderPathTest do
render_click(view, "select_tab", %{"type" => "post", "id" => post.id})
render_click(view, "select_panel_tab", %{"tab" => "post_links"})
query_count = count_queries(fn ->
1..10 |> Enum.each(fn _ -> render(view) end)
end)
query_count =
count_queries(fn ->
1..10 |> Enum.each(fn _ -> render(view) end)
end)
assert query_count == 0,
"Expected 0 DB queries on panel re-renders, got #{query_count}"
"Expected 0 DB queries on panel re-renders, got #{query_count}"
end
test "git_log panel re-render uses cached assigns", %{post: post} do
@@ -51,12 +52,13 @@ defmodule BDS.CSM008RenderPathTest do
render_click(view, "select_tab", %{"type" => "post", "id" => post.id})
render_click(view, "select_panel_tab", %{"tab" => "git_log"})
query_count = count_queries(fn ->
1..10 |> Enum.each(fn _ -> render(view) end)
end)
query_count =
count_queries(fn ->
1..10 |> Enum.each(fn _ -> render(view) end)
end)
assert query_count == 0,
"Expected 0 DB queries on git_log re-renders, got #{query_count}"
"Expected 0 DB queries on git_log re-renders, got #{query_count}"
end
end
@@ -64,26 +66,28 @@ defmodule BDS.CSM008RenderPathTest do
test "switching to output panel triggers no post/media queries" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
{query_count, query_sources} = count_queries_with_sources(fn ->
render_click(view, "select_panel_tab", %{"tab" => "output"})
end)
{query_count, query_sources} =
count_queries_with_sources(fn ->
render_click(view, "select_panel_tab", %{"tab" => "output"})
end)
refute "post_links" in query_sources,
"Switching to output should not query post_links, got: #{inspect(query_sources)}"
"Switching to output should not query post_links, got: #{inspect(query_sources)}"
assert query_count == 0,
"Expected 0 queries for output panel tab, got #{query_count}"
"Expected 0 queries for output panel tab, got #{query_count}"
end
test "switching to tasks panel triggers no post/media queries" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
{query_count, _query_sources} = count_queries_with_sources(fn ->
render_click(view, "select_panel_tab", %{"tab" => "tasks"})
end)
{query_count, _query_sources} =
count_queries_with_sources(fn ->
render_click(view, "select_panel_tab", %{"tab" => "tasks"})
end)
assert query_count == 0,
"Expected 0 queries for tasks panel tab, got #{query_count}"
"Expected 0 queries for tasks panel tab, got #{query_count}"
end
end
@@ -98,12 +102,13 @@ defmodule BDS.CSM008RenderPathTest do
"subtitle" => "preset subtitle"
})
query_count = count_queries(fn ->
render_click(view, "select_tab", %{"type" => "post", "id" => post.id})
end)
query_count =
count_queries(fn ->
render_click(view, "select_tab", %{"type" => "post", "id" => post.id})
end)
assert query_count == 0,
"Expected 0 DB queries when tab meta is already complete, got #{query_count}"
"Expected 0 DB queries when tab meta is already complete, got #{query_count}"
end
end

View File

@@ -34,7 +34,10 @@ defmodule BDS.CSM009ThumbnailErrorHandlingTest do
assert {:error, _reason} = result
end
test "ensure_thumbnails returns :ok for non-image media", %{project: project, temp_dir: temp_dir} do
test "ensure_thumbnails returns :ok for non-image media", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "readme.txt")
File.write!(source_path, "just text")
@@ -86,11 +89,15 @@ defmodule BDS.CSM009ThumbnailErrorHandlingTest do
} do
source_path = Path.join(temp_dir, "good.jpg")
File.write!(source_path, tiny_jpeg_binary())
{:ok, good_media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
{:ok, good_media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
corrupt_path = Path.join(temp_dir, "bad.jpg")
File.write!(corrupt_path, "corrupt data")
{:ok, bad_media} = BDS.Media.import_media(%{project_id: project.id, source_path: corrupt_path})
{:ok, bad_media} =
BDS.Media.import_media(%{project_id: project.id, source_path: corrupt_path})
Enum.each(Map.values(Thumbnails.thumbnail_paths(good_media)), fn path ->
File.rm(Path.join(temp_dir, path))

View File

@@ -79,7 +79,9 @@ defmodule BDS.CSM020NestedCaseTest do
describe "Publishing.handle_call :update_job uses with" do
test "source code uses with instead of case" do
source = File.read!("lib/bds/publishing.ex")
[func_source] = Regex.scan(~r/def handle_call\(\{:update_job.*?(?=\n def |\n @impl)/s, source)
[func_source] =
Regex.scan(~r/def handle_call\(\{:update_job.*?(?=\n def |\n @impl)/s, source)
assert func_source |> List.first() |> String.contains?("with"),
"update_job handler should use with"

View File

@@ -4,6 +4,7 @@ defmodule BDS.CSM031TryRescueTest do
describe "source-level: no inline try/rescue around Liquex.render!" do
test "filters.ex has no try/rescue block in render_macro_source" do
source = File.read!("lib/bds/rendering/filters.ex")
refute source =~ ~r/try do\s+.*Liquex\.render!/s,
"render_macro_source should use safe_liquex_render helper, not inline try/rescue"
end
@@ -51,6 +52,7 @@ defmodule BDS.CSM031TryRescueTest do
test "template_selection.ex uses FileSystem.try_read instead of read_template_file" do
source = File.read!("lib/bds/rendering/template_selection.ex")
refute source =~ "read_template_file",
"should use FileSystem.try_read, not the raising read_template_file"

View File

@@ -35,12 +35,14 @@ defmodule BDS.CSM032MapGetPatternMatchTest do
describe "source-level: overlay.ex uses pattern matching for known structures" do
test "delete_details uses pattern matching instead of Map.get" do
source = File.read!("lib/bds/desktop/overlay.ex")
refute source =~ "Map.get(delete_details,",
"overlay.ex should pattern match delete_details instead of using Map.get"
end
test "merge_details uses pattern matching instead of Map.get" do
source = File.read!("lib/bds/desktop/overlay.ex")
refute source =~ "Map.get(merge,",
"overlay.ex should pattern match merge_details instead of using Map.get"
end
@@ -65,32 +67,40 @@ defmodule BDS.CSM032MapGetPatternMatchTest do
describe "source-level: generation pipeline uses dot access for Post struct fields" do
test "outputs.ex uses post.language instead of Map.get(post, :language)" do
source = File.read!("lib/bds/generation/outputs.ex")
refute source =~ "Map.get(post, :language)",
"outputs.ex should use post.language"
end
test "data.ex uses dot access for Post struct fields in build_published_translation_variant" do
source = File.read!("lib/bds/generation/data.ex")
refute source =~ "Map.get(post, :author)",
"data.ex should use post.author"
refute source =~ "Map.get(post, :tags",
"data.ex should use post.tags"
refute source =~ "Map.get(post, :categories",
"data.ex should use post.categories"
refute source =~ "Map.get(post, :template_slug)",
"data.ex should use post.template_slug"
refute source =~ "Map.get(post, :do_not_translate",
"data.ex should use post.do_not_translate"
end
test "validation.ex uses post.file_path instead of Map.get(post, :file_path)" do
source = File.read!("lib/bds/generation/validation.ex")
refute source =~ "Map.get(post, :file_path)",
"validation.ex should use post.file_path"
end
test "sitemap.ex uses post.do_not_translate instead of Map.get" do
source = File.read!("lib/bds/generation/sitemap.ex")
refute source =~ "Map.get(post, :do_not_translate)",
"sitemap.ex should use post.do_not_translate"
end

View File

@@ -118,9 +118,7 @@ defmodule BDS.CSM033BatchInsertsTest do
assert Enum.all?(posts, fn post -> post.id in indexed end)
keys =
BDS.Repo.all(
from(k in BDS.Embeddings.Key, where: k.project_id == ^project.id)
)
BDS.Repo.all(from(k in BDS.Embeddings.Key, where: k.project_id == ^project.id))
assert length(keys) == 5
labels = Enum.map(keys, & &1.label) |> Enum.sort()
@@ -141,7 +139,8 @@ defmodule BDS.CSM033BatchInsertsTest do
original_key =
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
{:ok, _post} = BDS.Posts.update_post(post.id, %{content: "completely different content now"})
{:ok, _post} =
BDS.Posts.update_post(post.id, %{content: "completely different content now"})
{:ok, rebuilt_ids} = BDS.Embeddings.rebuild_project(project.id)
assert post.id in rebuilt_ids
@@ -175,9 +174,7 @@ defmodule BDS.CSM033BatchInsertsTest do
assert repaired == [post_a.id]
keys =
BDS.Repo.all(
from(k in BDS.Embeddings.Key, where: k.project_id == ^project.id)
)
BDS.Repo.all(from(k in BDS.Embeddings.Key, where: k.project_id == ^project.id))
assert length(keys) == 2
end
@@ -206,5 +203,4 @@ defmodule BDS.CSM033BatchInsertsTest do
assert key_before.vector == key_after.vector
end
end
end

View File

@@ -21,7 +21,9 @@ defmodule BDS.CSM034FileReadBangTest do
test "release_packaging.ex has no File.read! or File.write!" do
source = File.read!("lib/bds/release_packaging.ex")
refute source =~ "File.read!", "release_packaging.ex should use File.read, not File.read!"
refute source =~ "File.write!", "release_packaging.ex should use File.write, not File.write!"
refute source =~ "File.write!",
"release_packaging.ex should use File.write, not File.write!"
end
end

View File

@@ -49,7 +49,9 @@ defmodule BDS.CSM035ProcessDictTest do
match = Regex.run(~r/def sidebar_content\(assigns\).*?\n(.*?)\n/s, source)
assert match, "could not find sidebar_content/1"
[_, first_line | _] = match
assert first_line =~ "UILocale.put", "sidebar_content/1 must call UILocale.put on its first line"
assert first_line =~ "UILocale.put",
"sidebar_content/1 must call UILocale.put on its first line"
end
test "MenuBar.mount/1 calls UILocale.put" do
@@ -57,7 +59,9 @@ defmodule BDS.CSM035ProcessDictTest do
match = Regex.run(~r/def mount\(menu\).*?\n(.*?)\n/s, source)
assert match, "could not find mount/1 in menu_bar.ex"
[_, first_line | _] = match
assert first_line =~ "UILocale.put", "MenuBar.mount/1 must call UILocale.put on its first line"
assert first_line =~ "UILocale.put",
"MenuBar.mount/1 must call UILocale.put on its first line"
end
end

View File

@@ -100,7 +100,9 @@ defmodule BDS.Desktop.AutomationTest do
assert :ok = Automation.reload(session)
snapshot = await(session, &(&1.sidebar_visible == true and &1.sidebar_width >= resized_width - 2))
snapshot =
await(session, &(&1.sidebar_visible == true and &1.sidebar_width >= resized_width - 2))
assert snapshot.sidebar_visible == true
assert snapshot.sidebar_width >= resized_width - 2
assert snapshot.sidebar_width <= resized_width + 2

View File

@@ -0,0 +1,34 @@
defmodule BDS.Desktop.DeepLinkTest do
use ExUnit.Case, async: false
alias BDS.Desktop.DeepLink
setup do
topic = "deep-link-test:#{System.unique_integer([:positive])}"
Phoenix.PubSub.subscribe(BDS.PubSub, topic)
{:ok, pid} = DeepLink.start_link(name: nil, pubsub: BDS.PubSub, topic: topic)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
%{pid: pid, topic: topic}
end
test "broadcasts a bds2:// open_url event to the shell topic", %{pid: pid} do
url = "bds2://new-post?title=Hello&url=https://example.com"
send(pid, {:open_url, [url]})
assert_receive {:blogmark_deep_link, ^url}, 500
end
test "ignores non-bds2 open_url events", %{pid: pid} do
send(pid, {:open_url, ["https://example.com"]})
refute_receive {:blogmark_deep_link, _url}, 200
end
test "ignores unrelated messages", %{pid: pid} do
send(pid, {:open_file, ["/tmp/x"]})
refute_receive {:blogmark_deep_link, _url}, 200
end
end

View File

@@ -0,0 +1,107 @@
defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategoriesTest do
use ExUnit.Case, async: false
alias BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories
describe "protected_category?/1" do
test "returns true for article, aside, page, picture" do
assert ManagedCategories.protected_category?("article")
assert ManagedCategories.protected_category?("aside")
assert ManagedCategories.protected_category?("page")
assert ManagedCategories.protected_category?("picture")
end
test "returns false for non-protected categories" do
refute ManagedCategories.protected_category?("news")
refute ManagedCategories.protected_category?("links")
refute ManagedCategories.protected_category?("random")
end
end
describe "remove_category/4" do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir =
Path.join(System.tmp_dir!(), "bds-managed-categories-#{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: "ProtectedCategories", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
test "rejects deletion of protected category with error output", %{project: project} do
socket = %{assigns: %{projects: %{active_project_id: project.id}, workbench: nil}}
append_output = fn _socket, _title, _msg, _nil, _kind ->
send(self(), :error_appended)
socket
end
ManagedCategories.remove_category(
socket,
"article",
fn s, _wb -> s end,
append_output
)
assert_received :error_appended
end
test "rejects deletion of all protected categories", %{project: project} do
socket = %{assigns: %{projects: %{active_project_id: project.id}, workbench: nil}}
for cat <- ["article", "aside", "page", "picture"] do
append_output = fn _socket, _title, _msg, _nil, _kind ->
send(self(), {:rejected, cat})
socket
end
ManagedCategories.remove_category(
socket,
cat,
fn s, _wb -> s end,
append_output
)
assert_received {:rejected, ^cat}
end
end
test "allows deletion of non-protected category via Metadata.remove_category", %{
project: project
} do
socket = %{
assigns: %{
projects: %{active_project_id: project.id},
workbench: nil
}
}
BDS.Metadata.add_category(project.id, "test-cat")
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
assert "test-cat" in meta.categories
append_output = fn _socket, _title, _msg, _nil, _kind ->
send(self(), :error_called)
socket
end
ManagedCategories.remove_category(
socket,
"test-cat",
fn s, _wb -> s end,
append_output
)
refute_received :error_called
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
refute "test-cat" in meta.categories
end
end
end

View File

@@ -0,0 +1,108 @@
defmodule BDS.Desktop.MenuEditor.HomeItemProtectionTest do
use ExUnit.Case, async: true
alias BDS.Desktop.ShellLive.MenuEditor.{TreeOps, TreePredicates}
@home_id TreeOps.home_item_id()
@other_id "some-other-item"
defp home_state do
%{
items: [
TreeOps.home_item(),
%{item_id: @other_id, kind: :page, label: "About", slug: "about", children: [], is_home: false}
],
selected_id: @home_id,
draft: nil
}
end
defp other_state do
%{
items: [
TreeOps.home_item(),
%{item_id: @other_id, kind: :page, label: "About", slug: "about", children: [], is_home: false}
],
selected_id: @other_id,
draft: nil
}
end
describe "TreePredicates" do
test "can_move_up? returns false for home item" do
items = home_state().items
refute TreePredicates.can_move_up?(items, @home_id)
end
test "can_move_down? returns false for home item" do
items = home_state().items
refute TreePredicates.can_move_down?(items, @home_id)
end
test "can_indent? returns false for home item" do
items = home_state().items
refute TreePredicates.can_indent?(items, @home_id)
end
test "can_unindent? returns false for home item" do
items = home_state().items
refute TreePredicates.can_unindent?(items, @home_id)
end
test "can_delete? returns false for home item" do
refute TreePredicates.can_delete?(@home_id)
end
test "can_delete? returns true for non-home item" do
assert TreePredicates.can_delete?(@other_id)
end
end
describe "TreeOps" do
test "move_selected is no-op when home item is selected" do
state = home_state()
assert TreeOps.move_selected(state, :up) == state
assert TreeOps.move_selected(state, :down) == state
end
test "move_selected works for non-home item" do
state = other_state()
moved = TreeOps.move_selected(state, :up)
assert moved != state
end
test "indent_selected is no-op when home item is selected" do
state = home_state()
assert TreeOps.indent_selected(state) == state
end
test "unindent_selected is no-op when home item is selected" do
state = home_state()
assert TreeOps.unindent_selected(state) == state
end
test "delete_selected is no-op when home item is selected" do
state = home_state()
assert TreeOps.delete_selected(state) == state
end
test "drop_selected is no-op when drag item is home item" do
state = other_state()
assert TreeOps.drop_selected(state, @home_id, @other_id, "after") == state
end
test "drop_selected works when neither item is home" do
state = %{
other_state()
| items: [
%{item_id: "a", kind: :page, label: "A", slug: "a", children: [], is_home: false},
%{item_id: "b", kind: :page, label: "B", slug: "b", children: [], is_home: false}
],
selected_id: "b"
}
dropped = TreeOps.drop_selected(state, "a", "b", "before")
assert dropped != state
end
end
end

View File

@@ -846,6 +846,27 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-id="#{created_definition.id}")
end
test "blogmark deep link creates a draft post and opens it in the editor", %{project: project} do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
post_count_before = Repo.aggregate(Post, :count, :id)
url =
"bds2://new-post?title=" <>
URI.encode_www_form("Saved From Browser") <>
"&url=" <> URI.encode_www_form("https://example.com/article")
send(view.pid, {:blogmark_deep_link, url})
html = render(view)
assert Repo.aggregate(Post, :count, :id) == post_count_before + 1
created_post = Repo.get_by!(Post, title: "Saved From Browser")
assert created_post.project_id == project.id
assert created_post.status == :draft
assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="#{created_post.id}")
end
test "settings sidebar selections expose a scroll target for the preferences editor" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)

View File

@@ -315,9 +315,15 @@ defmodule BDS.DesktopTest do
assert conn.resp_body =~ ~s(data-testid="toggle-sidebar")
assert conn.resp_body =~ ~s(data-testid="toggle-panel")
assert conn.resp_body =~ ~s(data-testid="toggle-assistant")
assert conn.resp_body =~ ~s(class="activity-bar flex h-full shrink-0 flex-col items-center justify-between")
assert conn.resp_body =~
~s(class="activity-bar flex h-full shrink-0 flex-col items-center justify-between")
assert conn.resp_body =~ ~s(class="sidebar flex min-w-0 flex-1 overflow-hidden")
assert conn.resp_body =~ ~s(class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2")
assert conn.resp_body =~
~s(class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2")
assert conn.resp_body =~ ~s(data-phx-main)
assert conn.resp_body =~ ~s(href="/assets/app.css")
assert conn.resp_body =~ ~s(src="/assets/app.js")

View File

@@ -0,0 +1,147 @@
defmodule BDS.EditorImageDropTest do
@moduledoc """
Covers the drag-and-drop image chain (action_patterns.allium
DragDropImageChain / editor_post.allium PostDragDropImage):
1. import media (file copy + base sidecar)
2. generate thumbnails synchronously
3. link media to the post
4. insert `![](bds-media://id)` at the cursor
Steps 5-6 (AI analysis + auto-translate) are background AI activities gated
behind airplane mode and are not exercised here.
"""
use ExUnit.Case, async: false
import Phoenix.ConnTest
import Phoenix.LiveViewTest
alias BDS.Desktop.ShellLive.EditorImageDrop
alias BDS.{AI, Media, 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-editor-drop-#{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: "Drop Test", data_path: temp_dir})
{:ok, _project} = BDS.Projects.set_active_project(project.id)
{:ok, post} =
BDS.Posts.create_post(%{project_id: project.id, title: "Drop Post", content: "Body"})
%{project: project, post: post, temp_dir: temp_dir}
end
defp write_image!(path) do
File.write!(path, Image.new!(4, 4, color: [255, 0, 0]) |> Image.write!(:memory, suffix: ".jpg"))
path
end
describe "EditorImageDrop.import_and_link/3" do
test "imports, generates thumbnails, links the post, and returns image markdown", %{
project: project,
post: post,
temp_dir: temp_dir
} do
source = write_image!(Path.join(temp_dir, "dropped.jpg"))
assert {:ok, media, markdown} =
EditorImageDrop.import_and_link(project.id, post.id, source)
# Step 1: media row + file copy.
assert Repo.get(Media.Media, media.id)
assert File.exists?(Path.join(temp_dir, media.file_path))
# Step 2: thumbnails generated synchronously.
thumbnails = Media.thumbnail_paths(media)
assert thumbnails != %{}
Enum.each(Map.values(thumbnails), fn path ->
assert File.exists?(Path.join(temp_dir, path)), "missing thumbnail #{path}"
end)
# Step 3: linked to the post.
assert [linked] = Media.list_linked_posts(media.id)
assert linked.post_id == post.id
# Step 4: markdown reference inserted at the cursor.
assert markdown == "![](bds-media://#{media.id})"
end
test "non-image files yield a plain link reference", %{
project: project,
post: post,
temp_dir: temp_dir
} do
source = Path.join(temp_dir, "notes.txt")
File.write!(source, "plain text")
assert {:ok, media, markdown} =
EditorImageDrop.import_and_link(project.id, post.id, source)
assert markdown == "[#{media.original_name}](bds-media://#{media.id})"
end
end
describe "drag-drop event in the post editor" do
setup do
prev = System.get_env("BDS_DESKTOP_AUTOMATION")
System.put_env("BDS_DESKTOP_AUTOMATION", "1")
on_exit(fn ->
if prev,
do: System.put_env("BDS_DESKTOP_AUTOMATION", prev),
else: System.delete_env("BDS_DESKTOP_AUTOMATION")
end)
:ok
end
test "dropping an image imports, links, and inserts markdown at the cursor", %{
post: post,
temp_dir: temp_dir
} do
source = write_image!(Path.join(temp_dir, "editor-drop.jpg"))
# Airplane mode keeps the background AI steps (5-6) out of the test while
# the synchronous chain (1-4) must still complete.
:ok = AI.set_airplane_mode(true)
{: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"
})
view
|> element("[data-monaco-drop-event='editor_image_dropped']")
|> render_hook("editor_image_dropped", %{"path" => source})
assert_push_event(view, "post-editor-insert-content", %{content: content})
assert content =~ ~r{^!\[\]\(bds-media://[0-9a-f-]+\)$}
[media] = Repo.all(Media.Media)
assert media.project_id == post.project_id
assert content == "![](bds-media://#{media.id})"
assert [linked] = Media.list_linked_posts(media.id)
assert linked.post_id == post.id
# Synchronous steps ran despite airplane mode; no AI metadata applied.
assert is_nil(media.title)
:ok = AI.set_airplane_mode(false)
end
end
end

View File

@@ -624,4 +624,71 @@ defmodule BDS.EmbeddingsTest do
# Queries stay safe.
assert {:ok, []} = BDS.Embeddings.find_similar(post.id, 5)
end
# DebouncedPersistence invariant (embedding.allium:213-217): HNSW index
# persistence is debounced at 5s so bulk operations don't thrash the disk;
# rapid writes coalesce into one deferred save, force-saved via flush.
describe "DebouncedPersistence invariant" do
alias BDS.Embeddings.Index
defp packed_vector(seed) do
for offset <- 1..384, into: <<>>, do: <<:math.sin(seed + offset)::float-32-little>>
end
defp index_entries do
[
%{label: 1, post_id: 101, vector: packed_vector(1)},
%{label: 2, post_id: 102, vector: packed_vector(2)}
]
end
test "put schedules a ~5s save timer instead of writing to disk immediately", %{
project: project
} do
refute File.exists?(Index.path(project.id))
:ok = Index.put(project.id, 384, index_entries())
entry = Map.fetch!(:sys.get_state(Index), project.id)
assert is_reference(entry.timer)
# the debounce window is 5 seconds
remaining = Process.read_timer(entry.timer)
assert is_integer(remaining)
assert remaining > 4_000 and remaining <= 5_000
# nothing flushed to disk yet — persistence is deferred
refute File.exists?(Index.path(project.id))
end
test "rapid puts coalesce: each reschedules the single debounce timer", %{project: project} do
:ok = Index.put(project.id, 384, index_entries())
first_timer = Map.fetch!(:sys.get_state(Index), project.id).timer
:ok = Index.put(project.id, 384, index_entries())
second_timer = Map.fetch!(:sys.get_state(Index), project.id).timer
# a new timer replaced the old one (debounce reset) and the old one was
# cancelled, so two writes still produce only one pending save
refute first_timer == second_timer
assert Process.read_timer(first_timer) == false
refute File.exists?(Index.path(project.id))
end
test "the debounce timer firing flushes to disk and clears the pending timer", %{
project: project
} do
:ok = Index.put(project.id, 384, index_entries())
refute File.exists?(Index.path(project.id))
# simulate the 5s debounce elapsing
send(Index, {:save, project.id})
# synchronous round-trip ensures the {:save, _} message was processed first
entry = Map.fetch!(:sys.get_state(Index), project.id)
assert File.exists?(Index.path(project.id))
assert is_nil(entry.timer)
end
end
end

View File

@@ -19,7 +19,9 @@ defmodule BDS.ImageImportPipelineTest do
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})
{:ok, project} =
BDS.Projects.create_project(%{name: "Image Import Test", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
@@ -40,7 +42,8 @@ defmodule BDS.ImageImportPipelineTest do
)
assert result ==
{:ok, ["/Users/test/photo1.jpg", "/Users/test/photo2.png", "/Users/test/photo3.heic"]}
{:ok,
["/Users/test/photo1.jpg", "/Users/test/photo2.png", "/Users/test/photo3.heic"]}
end
test "multi selection filters out empty lines" do
@@ -51,7 +54,8 @@ defmodule BDS.ImageImportPipelineTest do
)
assert result ==
{:ok, ["/Users/test/photo1.jpg", "/Users/test/photo2.png", "/Users/test/photo3.heic"]}
{: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

View File

@@ -32,7 +32,10 @@ defmodule BDS.ImportDefinitionsTest do
result = ImportDefinitions.decode_analysis_result(malicious_json)
assert is_map(result)
assert Map.get(result, unknown_key_1) == "val" or Map.get(result, "csm001_fictive_#{unique_suffix}") == "val"
assert Map.get(result, unknown_key_1) == "val" or
Map.get(result, "csm001_fictive_#{unique_suffix}") == "val"
assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_key_1) end
assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_key_2) end
end

View File

@@ -83,9 +83,10 @@ defmodule BDS.MapUtilsTest do
test "safe_atomize_keys does not create atoms for malicious payloads" do
unique_suffix = :erlang.unique_integer()
malicious = for i <- 1..500, into: %{} do
{"csm001_malicious_#{i}_#{unique_suffix}", "val"}
end
malicious =
for i <- 1..500, into: %{} do
{"csm001_malicious_#{i}_#{unique_suffix}", "val"}
end
result = MapUtils.safe_atomize_keys(malicious)

View File

@@ -119,6 +119,79 @@ defmodule BDS.MediaTest do
end)
end
test "replace_media_file copies the new file over the path, updates the row, and regenerates thumbnails synchronously",
%{project: project, temp_dir: temp_dir} do
source_path = Path.join(temp_dir, "original.jpg")
File.write!(source_path, Image.new!(4, 4, color: [255, 0, 0]) |> Image.write!(:memory, suffix: ".jpg"))
assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
assert media.width == 4
assert media.height == 4
thumbnail_paths = BDS.Media.thumbnail_paths(media)
# Delete thumbnails so their presence after the replace proves regeneration ran.
Enum.each(Map.values(thumbnail_paths), fn path ->
File.rm!(Path.join(temp_dir, path))
end)
replacement_path = Path.join(temp_dir, "replacement.jpg")
File.write!(replacement_path, Image.new!(6, 8, color: [0, 0, 255]) |> Image.write!(:memory, suffix: ".jpg"))
replacement_binary = File.read!(replacement_path)
assert {:ok, updated} = BDS.Media.replace_media_file(media.id, replacement_path)
refute updated.checksum == media.checksum
assert updated.width == 6
assert updated.height == 8
assert updated.size == byte_size(replacement_binary)
# New file content is on disk at the same path.
assert File.read!(Path.join(temp_dir, updated.file_path)) == replacement_binary
# Thumbnails were regenerated synchronously (present immediately, no backup left behind).
Enum.each(Map.values(BDS.Media.thumbnail_paths(updated)), fn path ->
assert File.exists?(Path.join(temp_dir, path))
end)
refute File.exists?(Path.join(temp_dir, updated.file_path) <> ".bak")
end
test "replace_media_file is a no-op when the new file matches the current checksum", %{
project: project,
temp_dir: temp_dir
} do
source_path = Path.join(temp_dir, "same.jpg")
File.write!(source_path, Image.new!(4, 4, color: [255, 0, 0]) |> Image.write!(:memory, suffix: ".jpg"))
assert {:ok, media} =
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
# First replace establishes a stored checksum (import does not compute one).
new_path = Path.join(temp_dir, "blue.jpg")
File.write!(new_path, Image.new!(6, 8, color: [0, 0, 255]) |> Image.write!(:memory, suffix: ".jpg"))
assert {:ok, updated} = BDS.Media.replace_media_file(media.id, new_path)
refute is_nil(updated.checksum)
# Replacing with the identical file is a no-op.
identical_path = Path.join(temp_dir, "blue_copy.jpg")
File.cp!(new_path, identical_path)
assert {:ok, nil} = BDS.Media.replace_media_file(media.id, identical_path)
end
test "replace_media_file returns {:error, :not_found} for an unknown media id", %{
temp_dir: temp_dir
} do
replacement_path = Path.join(temp_dir, "orphan.jpg")
File.write!(replacement_path, Image.new!(4, 4, color: [0, 255, 0]) |> Image.write!(:memory, suffix: ".jpg"))
assert {:error, :not_found} = BDS.Media.replace_media_file("does-not-exist", replacement_path)
end
test "deleting a post rewrites linked media sidecars to remove that post id", %{
project: project,
temp_dir: temp_dir

View File

@@ -229,6 +229,7 @@ defmodule BDS.PostsTest do
File.write!(old_full, "stale content")
import Ecto.Query
BDS.Repo.update_all(
from(p in BDS.Posts.Post, where: p.id == ^published.id),
set: [file_path: old_relative, content: "edited body"]
@@ -445,6 +446,52 @@ defmodule BDS.PostsTest do
assert {:error, :not_found} = BDS.Posts.unarchive_post(Ecto.UUID.generate())
end
test "discard_post_changes restores the published version from file and updates the FTS index" do
temp_dir =
Path.join(System.tmp_dir!(), "bds-post-discard-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
assert {:ok, project} =
BDS.Projects.create_project(%{name: "Discard FTS", data_path: temp_dir})
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Pristine Headline",
content: "pristine meadow body"
})
assert {:ok, published} = BDS.Posts.publish_post(post.id)
assert published.status == :published
assert published.content == nil
# The published file is the source of truth; the FTS index reflects it.
assert {:ok, %{posts: [%{id: post_id}]}} = BDS.Search.search_posts(project.id, "pristine")
assert post_id == post.id
# Make the DB diverge from the file with unsaved edits, re-indexing the dirty text.
assert {:ok, dirty} =
BDS.Posts.update_post(post.id, %{
title: "Tampered Headline",
content: "tampered swamp body"
})
assert dirty.content == "tampered swamp body"
assert {:ok, %{posts: [%{id: ^post_id}]}} = BDS.Search.search_posts(project.id, "tampered")
assert {:ok, %{posts: []}} = BDS.Search.search_posts(project.id, "pristine")
# Discarding restores the published file version and re-syncs the FTS index.
assert {:ok, restored} = BDS.Posts.discard_post_changes(post.id)
assert restored.status == :published
assert restored.content == nil
assert restored.title == "Pristine Headline"
assert {:ok, %{posts: [%{id: ^post_id}]}} = BDS.Search.search_posts(project.id, "pristine")
assert {:ok, %{posts: []}} = BDS.Search.search_posts(project.id, "tampered")
end
test "rebuild_posts_from_files recreates published posts from disk" do
temp_dir =
Path.join(System.tmp_dir!(), "bds-post-rebuild-#{System.unique_integer([:positive])}")

View File

@@ -424,9 +424,10 @@ defmodule BDS.PreviewTest do
assert :ok = BDS.Preview.stop_preview(project.id)
end
test "on-demand rendering: published post route renders via template without generated files", %{
project: project
} do
test "on-demand rendering: published post route renders via template without generated files",
%{
project: project
} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "en",
@@ -458,9 +459,10 @@ defmodule BDS.PreviewTest do
assert :ok = BDS.Preview.stop_preview(project.id)
end
test "on-demand rendering: home page renders published posts as list without generated files", %{
project: project
} do
test "on-demand rendering: home page renders published posts as list without generated files",
%{
project: project
} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
main_language: "en",

View File

@@ -96,7 +96,9 @@ defmodule BDS.Scripts.TransformsTest do
data.content = data.content .. "D"
return data
end
""", enabled: false)
""",
enabled: false
)
transform(other.id, "Foreign", """
function main(data, _ctx)
@@ -165,6 +167,54 @@ defmodule BDS.Scripts.TransformsTest do
assert [%{reason: _}] = result.errors
end
test "a failing first transform does not halt the pipeline (TransformPipelineContinuation)", %{
project: project
} do
# First transform fails before producing any valid state; later transforms
# must still run against the original input, and each failure is captured
# with its slug without halting the pipeline.
boom =
transform(project.id, "Boom", """
function main(_data, _ctx)
error("boom")
end
""")
Process.sleep(5)
transform(project.id, "Append", """
function main(data, _ctx)
data.content = data.content .. "A"
return data
end
""")
Process.sleep(5)
kaboom =
transform(project.id, "Kaboom", """
function main(_data, _ctx)
error("kaboom")
end
""")
data = %{
"title" => "t",
"content" => "seed",
"tags" => [],
"categories" => [],
"url" => "http://x"
}
assert {:ok, result} = Transforms.run(project.id, data)
# Original input survives the first failure; the surviving transform still runs.
assert result.data["content"] == "seedA"
# Both failures are captured, in pipeline order, each tagged with its slug.
assert [%{slug: first_slug}, %{slug: second_slug}] = result.errors
assert first_slug == boom.slug
assert second_slug == kaboom.slug
end
test "receives blogmark context with source and originating url", %{project: project} do
transform(project.id, "Ctx", """
function main(data, ctx)

View File

@@ -142,7 +142,8 @@ defmodule BDS.TemplateLookupPriorityTest do
describe "BundledDefaultTemplatesExistOutsideProjectData" do
test "single-post bundled template resolves with no Template rows", %{project: project} do
assert [] = BDS.Repo.all(from t in BDS.Templates.Template, where: t.project_id == ^project.id)
assert [] =
BDS.Repo.all(from t in BDS.Templates.Template, where: t.project_id == ^project.id)
{:ok, source} = TemplateSelection.load_template_source(project.id, :post, nil)
@@ -150,7 +151,8 @@ defmodule BDS.TemplateLookupPriorityTest do
end
test "post-list bundled template resolves with no Template rows", %{project: project} do
assert [] = BDS.Repo.all(from t in BDS.Templates.Template, where: t.project_id == ^project.id)
assert [] =
BDS.Repo.all(from t in BDS.Templates.Template, where: t.project_id == ^project.id)
{:ok, source} = TemplateSelection.load_template_source(project.id, :list, nil)
@@ -158,7 +160,8 @@ defmodule BDS.TemplateLookupPriorityTest do
end
test "not-found bundled template resolves with no Template rows", %{project: project} do
assert [] = BDS.Repo.all(from t in BDS.Templates.Template, where: t.project_id == ^project.id)
assert [] =
BDS.Repo.all(from t in BDS.Templates.Template, where: t.project_id == ^project.id)
{:ok, source} = TemplateSelection.load_template_source(project.id, :not_found, nil)

View File

@@ -175,25 +175,39 @@ defmodule BDS.UI.ShellTest do
test "phase 3 templates use shared shell and form primitives for common layout" do
post_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex")
File.read!(
"/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex"
)
media_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex")
File.read!(
"/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex"
)
script_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/script_editor_html/script_editor.html.heex")
File.read!(
"/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/script_editor_html/script_editor.html.heex"
)
template_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/template_editor_html/template_editor.html.heex")
File.read!(
"/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/template_editor_html/template_editor.html.heex"
)
chat_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex")
File.read!(
"/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex"
)
menu_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/menu_editor_html/menu_editor.html.heex")
File.read!(
"/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/menu_editor_html/menu_editor.html.heex"
)
settings_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex")
File.read!(
"/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex"
)
assert post_template =~ "ui-editor-shell"
assert post_template =~ "ui-editor-header"
@@ -248,8 +262,12 @@ defmodule BDS.UI.ShellTest do
assistant_css = File.read!("/Users/gb/Projects/bDS2/assets/css/assistant.css")
menu_css = File.read!("/Users/gb/Projects/bDS2/assets/css/menu_editor.css")
refute editor_css =~ ".post-editor .editor-header,\n.scripts-view-shell.editor .editor-header,\n.templates-view-shell.editor .editor-header {\n display: flex;"
refute editor_css =~ ".post-editor .editor-actions,\n.scripts-view-shell.editor .editor-actions,\n.templates-view-shell.editor .editor-actions {\n display: flex;"
refute editor_css =~
".post-editor .editor-header,\n.scripts-view-shell.editor .editor-header,\n.templates-view-shell.editor .editor-header {\n display: flex;"
refute editor_css =~
".post-editor .editor-actions,\n.scripts-view-shell.editor .editor-actions,\n.templates-view-shell.editor .editor-actions {\n display: flex;"
refute editor_css =~ ".post-editor .quick-actions-menu {"
refute media_css =~ "[data-testid=\"media-editor\"] .editor-header {"
refute media_css =~ "[data-testid=\"media-editor\"] .editor-actions {"
@@ -287,9 +305,14 @@ defmodule BDS.UI.ShellTest do
assert css =~ ".chat-panel .chat-input-container"
assert css =~ ".chat-model-selector-menu"
assert css =~ "@media (max-width: 720px) {\n .chat-panel-header {\n align-items: stretch;\n flex-direction: column;"
assert css =~
"@media (max-width: 720px) {\n .chat-panel-header {\n align-items: stretch;\n flex-direction: column;"
assert css =~ ".chat-model-selector-wrap {\n width: 100%;"
assert css =~ ".chat-panel .chat-model-selector-button.chat-model-selector-inline {\n justify-content: space-between;\n width: 100%;"
assert css =~
".chat-panel .chat-model-selector-button.chat-model-selector-inline {\n justify-content: space-between;\n width: 100%;"
assert css =~ ".chat-panel .chat-input-container {\n padding: 8px 12px;"
end