Compare commits
17 Commits
ebf6136d2f
...
dfb2f8870b
| Author | SHA1 | Date | |
|---|---|---|---|
| dfb2f8870b | |||
| f0919f24a5 | |||
| 72f2c829ca | |||
| 7c7f629dd2 | |||
| dd760d0f2b | |||
| fb794ae833 | |||
| df0ae6a41b | |||
| e515cfacc6 | |||
| 7e9cc72e1f | |||
| 257a06e5d1 | |||
| 1b37f1fcec | |||
| 56caa653bb | |||
| 925fe97007 | |||
| d688c61b0e | |||
| 8db7bcf357 | |||
| 2bed225133 | |||
| 7045b10738 |
@@ -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: $?\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
36
SPECGAPS.md
36
SPECGAPS.md
@@ -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 `` 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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
150
lib/bds/blogmark.ex
Normal 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
|
||||
74
lib/bds/desktop/deep_link.ex
Normal file
74
lib/bds/desktop/deep_link.ex
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
110
lib/bds/desktop/shell_live/editor_image_drop.ex
Normal file
110
lib/bds/desktop/shell_live/editor_image_drop.ex
Normal 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): `` 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
|
||||
""
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -349,5 +349,4 @@ defmodule BDS.Rendering.ListArchive do
|
||||
do: RenderMetadata.calendar_initial_month(post)
|
||||
|
||||
defp calendar_initial_month_from_posts([]), do: nil
|
||||
|
||||
end
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -239,6 +239,7 @@ defmodule BDS.Tags do
|
||||
{:error, changeset} -> Repo.rollback(changeset)
|
||||
end
|
||||
end)
|
||||
|
||||
Enum.map(affected_posts, & &1.id)
|
||||
end)
|
||||
|> case do
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
}
|
||||
|
||||
@@ -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
126
test/bds/blogmark_test.exs
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
34
test/bds/desktop/deep_link_test.exs
Normal file
34
test/bds/desktop/deep_link_test.exs
Normal 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
|
||||
107
test/bds/desktop/managed_categories_test.exs
Normal file
107
test/bds/desktop/managed_categories_test.exs
Normal 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
|
||||
108
test/bds/desktop/menu_editor/home_item_protection_test.exs
Normal file
108
test/bds/desktop/menu_editor/home_item_protection_test.exs
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
147
test/bds/editor_image_drop_test.exs
Normal file
147
test/bds/editor_image_drop_test.exs
Normal 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 `` 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 == ""
|
||||
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 == ""
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user