Compare commits
16 Commits
dfb2f8870b
...
2ba8be2fc6
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ba8be2fc6 | |||
| 4731bc0cd2 | |||
| 8bc371eb3f | |||
| b65c2be29b | |||
| ee4d0dd33f | |||
| 8c71ece887 | |||
| b1438d5222 | |||
| 87f2f22241 | |||
| 60acda3fee | |||
| ab6a03dc54 | |||
| 0afb017e43 | |||
| cf553e2f78 | |||
| cb658aba1a | |||
| 544ff65e3b | |||
| 08eb9e4ea1 | |||
| 723a7ec1f7 |
123
SPECGAPS.md
123
SPECGAPS.md
@@ -64,36 +64,36 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
|
||||
| ~~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 |
|
||||
| ~~B1-5~~ | ~~`published_*` snapshot fields on Post for diffing~~ | `lib/bds/posts/post.ex:61-65` | **Resolved:** added published_title/content/tags/categories/excerpt snapshot fields to the Post entity in post.allium, noting their role in changes_affect_published_content / ReopenPublishedPost (schema.allium already listed them) |
|
||||
| ~~B1-6~~ | ~~Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering)~~ | `lib/bds/rendering/` | **Resolved:** new `rendering.allium` distills the shared render subsystem — 3 custom filters (i18n/markdown/slugify), markdown→HTML + URL rewriting, built-in `[[...]]` macros (youtube/vimeo/gallery/photo_archive/tag_cloud) with isolation invariant, links/languages (LinkContext, language prefix, backlinks), RenderLabels (content-language gettext + months), and post/list/not-found assign builders; added `SharedRenderPathForPreviewAndGeneration` invariant and wired into bds.allium |
|
||||
| ~~B1-7~~ | ~~404.html generation~~ | `lib/bds/generation/outputs.ex:344-345` | **Resolved:** added `FileGenerated("404.html")` (+ `{lang}/404.html` per blog language) to GenerateCoreSectionPages in generation.allium |
|
||||
| B1-8 | ~~`linkedPostIds` in media sidecar~~ | `lib/bds/media/sidecars.ex:42` | **Resolved:** added to MediaSidecar value in frontmatter.allium (with A2-15) |
|
||||
| B1-9 | ~~`projectId` in template/script frontmatter~~ | `templates.ex:337`, `scripts.ex:268` | **Resolved:** added projectId to TemplateFrontmatter and ScriptFrontmatter in frontmatter.allium (with A2-15) |
|
||||
| B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium |
|
||||
| B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium |
|
||||
| B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium |
|
||||
| B1-13 | `:confirm_dialog` generic confirmation | `shell_overlay.html.heex:171-187` | Add to modals.allium |
|
||||
| B1-14 | Publish actions for scripts and templates | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | Add to editor_script.allium, editor_template.allium |
|
||||
| B1-15 | `:import` as full editor tab | `lib/bds/ui/import_editor.ex` | Add to tabs.allium |
|
||||
| B1-16 | `:documentation`/`:api_documentation` tab types | `lib/bds/desktop/misc_editor/` | Add to tabs.allium |
|
||||
| B1-17 | Metadata diff covers embedding, media_translation, post_translation as entity types | `lib/bds/maintenance/repair.ex` | Add to metadata_diff.allium |
|
||||
| B1-18 | Finished task TTL eviction (1h, keep last 10) | `lib/bds/tasks.ex:365-386` | Add to task.allium |
|
||||
| B1-19 | `discard_post_changes/1` | `lib/bds/posts.ex:201-227` | Add to post.allium |
|
||||
| B1-20 | `replace_media_file/2` with checksum/backup | `lib/bds/media.ex:288-337` | Add to media.allium |
|
||||
| ~~B1-10~~ | ~~Media translation editing modal~~ | `media_editor.html.heex:275-303` | **Resolved:** editor_media.allium corrected — MediaTranslationEdit opens an "Edit Translation" modal (not inline); added `TranslationEditModal` guarantee (language hidden field + Title/Alt/Caption inputs, Cancel/Save footer) |
|
||||
| ~~B1-11~~ | ~~Menu editor drag-drop, indent/unindent/move~~ | `lib/bds/desktop/menu_editor/tree_ops.ex` | **Resolved:** already covered in editor_misc.allium — MenuMoveItem rule (up/down/indent/unindent), DragDrop + MoveDirections guarantees, HomeItemProtection (D1-18); verified against tree_ops.ex |
|
||||
| ~~B1-12~~ | ~~`:language_picker` overlay with flag emojis~~ | `shell_overlay.html.heex:116-139` | **Resolved:** already in modals.allium — LanguagePickerModal/LanguageTarget (flag emoji, name, existing-translation status badge); verified matches overlay code |
|
||||
| ~~B1-13~~ | ~~`:confirm_dialog` generic confirmation~~ | `shell_overlay.html.heex:171-187` | **Resolved:** already in modals.allium — ConfirmDialog (title/message, Cancel/Confirm); verified matches overlay code |
|
||||
| ~~B1-14~~ | ~~Publish actions for scripts and templates~~ | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | **Resolved:** added `can_publish` field, Publish button in HeaderLayout (shown only when draft), ScriptPublishRequested/TemplatePublishRequested surface events and ScriptPublish/TemplatePublish rules (validate-then-publish) to editor_script.allium + editor_template.allium |
|
||||
| ~~B1-15~~ | ~~`:import` as full editor tab~~ | `lib/bds/ui/import_editor.ex` | **Resolved:** already in tabs.allium (import singleton/pinned tab + editor route) and fully detailed as ImportAnalysisView in editor_misc.allium |
|
||||
| ~~B1-16~~ | ~~`:documentation`/`:api_documentation` tab types~~ | `lib/bds/desktop/misc_editor/` | **Resolved:** already in tabs.allium (both listed as singleton tabs + editor views: DOCUMENTATION.md / API.md) with DocumentationSurface in editor_misc.allium |
|
||||
| ~~B1-17~~ | ~~Metadata diff covers embedding, media_translation, post_translation as entity types~~ | `lib/bds/maintenance/repair.ex` | **Resolved:** metadata_diff.allium DiffReport entity_type list expanded to all 7 types; RunMetadataDiff notes media/media_translation/script/template/embedding (content_hash) coverage; added `RepairMetadataDiffItem` rule + surface event documenting the file_to_db/db_to_file dispatch per entity type |
|
||||
| ~~B1-18~~ | ~~Finished task TTL eviction (1h, keep last 10)~~ | `lib/bds/tasks.ex:365-386` | **Resolved:** added `finished_task_ttl` (1h) + `recent_finished_limit` (10) config, `FinishedTaskRetention` invariant, `EvictFinishedTasks` rule and `FinishedTaskEvictionDue` runtime event to task.allium |
|
||||
| ~~B1-19~~ | ~~`discard_post_changes/1`~~ | `lib/bds/posts.ex:201-227` | **Resolved:** added `DiscardPostChangesRequested` surface event + `DiscardPostChanges` rule (requires file_path, restores published file, content=null/status=published, re-syncs links+FTS) to post.allium |
|
||||
| ~~B1-20~~ | ~~`replace_media_file/2` with checksum/backup~~ | `lib/bds/media.ex:288-337` | **Resolved:** added `ReplaceMediaFileRequested` surface event + `ReplaceMediaFile` rule (md5 no-op skip, .bak backup/restore, updates checksum/size/dimensions, synchronous thumbnail regen, FTS re-sync) to media.allium |
|
||||
|
||||
### B2. Lower Priority (implementation detail or minor)
|
||||
|
||||
| ID | Behavior | Code Location |
|
||||
|---|---|---|
|
||||
| B2-1 | `editor_body/1` content resolver | `lib/bds/posts.ex:229-252` |
|
||||
| B2-2 | `sync_post_from_file/1` single-post reimport | `lib/bds/posts.ex:254-279` |
|
||||
| B2-3 | `import_orphan_post_file/1` | `lib/bds/posts.ex:289-291` |
|
||||
| B2-4 | `dashboard_stats/1`, `post_counts_by_year_month/1` | `lib/bds/posts.ex:378-413` |
|
||||
| B2-5 | `regenerate_missing_thumbnails/2` | `lib/bds/media.ex:47-48` |
|
||||
| B2-6 | Cache dir computation | `lib/bds/projects.ex:101-106` |
|
||||
| B2-7 | `remove_stale_published_templates` | `lib/bds/templates.ex:524-552` |
|
||||
| B2-8 | Rendering Labels module (30+ i18n strings) | `lib/bds/rendering/labels.ex` |
|
||||
| B2-9 | Progress reporting during reindex | `lib/bds/generation/progress.ex` |
|
||||
| ID | Behavior | Code Location | Resolution |
|
||||
|---|---|---|---|
|
||||
| ~~B2-1~~ | ~~`editor_body/1` content resolver~~ | `lib/bds/posts.ex:234-256` | **Resolved:** added `editor_body` derived field to the Post entity in post.allium (prefer DB draft content, else read markdown body from file, else empty; same for translations) |
|
||||
| ~~B2-2~~ | ~~`sync_post_from_file/1` single-post reimport~~ | `lib/bds/posts.ex:259` | **Resolved:** added `SyncPostFromFileRequested` surface event + `SyncPostFromFile` rule (re-read own .md file, upsert DB, re-sync links) to post.allium |
|
||||
| ~~B2-3~~ | ~~`import_orphan_post_file/1`~~ | `lib/bds/posts.ex:293` | **Resolved:** added `ImportOrphanPostFileRequested` surface event + `ImportOrphanPostFile` rule (import a disk .md with no DB row, reject non-markdown) to post.allium |
|
||||
| ~~B2-4~~ | ~~`dashboard_stats/1`, `post_counts_by_year_month/1`~~ | `lib/bds/posts.ex:416-450` | **Resolved:** added `ComputeDashboardData` rule to editor_misc.allium (status-grouped counts, (year,month) timeline newest-first limited to recent months, plus clouds/recent) |
|
||||
| ~~B2-5~~ | ~~`regenerate_missing_thumbnails/2`~~ | `lib/bds/media/thumbnails.ex:51` | **Resolved:** added `RegenerateMissingThumbnailsRequested` surface event + `RegenerateMissingThumbnails` rule (raster images excl. SVG, regenerate only missing files, background task w/ counts) to media_processing.allium |
|
||||
| ~~B2-6~~ | ~~Cache dir computation~~ | `lib/bds/projects.ex:142-147` | **Resolved:** added `cache_dir` derived field (`private_dir/projects/{id}`, :project_cache_root override) to the Project entity in project.allium |
|
||||
| ~~B2-7~~ | ~~`remove_stale_published_templates`~~ | `lib/bds/templates.ex:561` | **Resolved:** extended RebuildTemplatesFromFiles in template.allium to prune published templates whose file is neither scanned nor on disk (clearing references first) |
|
||||
| ~~B2-8~~ | ~~Rendering Labels module (30+ i18n strings)~~ | `lib/bds/rendering/labels.ex` | **Resolved:** captured as `RenderLabels` value + `LabelsUseContentLanguage` invariant in rendering.allium (with B1-6) — content-language gettext strings + month names |
|
||||
| ~~B2-9~~ | ~~Progress reporting during reindex~~ | `lib/bds/generation/progress.ex` | **Resolved:** added `GenerationProgressReported` runtime event + `ProgressReporting` guarantee to generation.allium (count-based + phased fractions, via task progress channel) |
|
||||
|
||||
---
|
||||
|
||||
@@ -138,47 +138,47 @@ All reconciled to follow code. Specs must be self-consistent and match code.
|
||||
|
||||
| ID | Claim | Spec | Path |
|
||||
|---|---|---|---|
|
||||
| D2-1 | RemoveCategory rule | metadata.allium:100 | Write test: remove category, verify list+settings+JSON updated |
|
||||
| D2-2 | CreateAndPublishTemplate rule | template.allium:105 | Write test: create+publish in one step |
|
||||
| D2-3 | CreateAndPublishScript rule | script.allium:160 | Write test: create+publish in one step |
|
||||
| D2-4 | UniqueScriptSlug dedup | script.allium:115 | Write test: two scripts same title → dedup slug |
|
||||
| D2-5 | FrontmatterRoundtrip invariant | post.allium:223 | Write test: write file, read back, assert all DB fields match |
|
||||
| D2-6 | SidecarRoundtrip invariant | media.allium:198 | Write test: write sidecar, read back, assert all fields match |
|
||||
| D2-7 | ConditionalPostFields: nil fields absent from frontmatter | frontmatter.allium:398 | Write test: post with nil excerpt/author/language → fields not in file |
|
||||
| D2-8 | ConditionalMediaFields: nil fields absent from sidecar | frontmatter.allium:417 | Write test: media with nil title/alt → fields not in sidecar |
|
||||
| D2-9 | max_posts_per_page 1..500 constraint | metadata.allium:75-77 | Write test: values outside range rejected |
|
||||
| D2-10 | SandboxedExecution: restricted capabilities blocked | script.allium:84-88 | Write test: filesystem/process/package loading blocked |
|
||||
| D2-11 | TransformToastBudget enforcement | script.allium:251-258 | Write test: per-script and total toast limits enforced |
|
||||
| D2-12 | ProgressThrottled: 250ms throttle | task.allium:110-113 | Write test: rapid progress reports throttled |
|
||||
| D2-13 | archived→draft transition | post.allium:121 | Write test: unarchive post → draft |
|
||||
| D2-14 | archived→published transition | post.allium:122 | Write test: unarchive post → published |
|
||||
| D2-15 | AppNoopNotifier: app writes don't produce notification rows | cli_sync.allium:64-68 | Write test: app mutation produces no notification row |
|
||||
| D2-16 | ValidateMedia rule | media_processing.allium:318-343 | Write test: missing/corrupted/orphan media detected |
|
||||
| D2-17 | ContentHashSkipsUnchanged during reindex | embedding.allium:199-202 | Write test: unchanged content_hash skips re-embedding |
|
||||
| ~~D2-1~~ | ~~RemoveCategory rule~~ | ~~metadata.allium:100~~ | **Resolved:** 2 tests added in metadata_test.exs — remove_category removes category+settings from state/files/DB (6 assertions); remove_category is a no-op for non-existent category |
|
||||
| ~~D2-2~~ | ~~CreateAndPublishTemplate rule~~ | template.allium:105 | **Resolved:** added `create_and_publish_template/1` (validates Liquid, creates published template + writs file), 3 tests added (happy path file assertions, invalid liquid rejection, slug dedup on title conflict) |
|
||||
| ~~D2-3~~ | ~~CreateAndPublishScript rule~~ | ~~script.allium:160~~ | **Resolved:** `create_and_publish_script/1` implemented in scripts.ex, 4 tests added (happy path with file assertions + macro entrypoint default + invalid Lua rejection + slug dedup on title conflict) |
|
||||
| ~~D2-4~~ | ~~UniqueScriptSlug dedup~~ | ~~script.allium:115~~ | **Resolved:** test added asserting two scripts with same title produce unique slugs (`dup-slug` → `dup-slug-2`) |
|
||||
| D2-5 | ~~FrontmatterRoundtrip invariant~~ | post.allium:223 | **Resolved:** test added — creates post with all fields, publishes, parses file back via `Frontmatter.parse_document`, asserts every field (id, title, slug, excerpt, status, author, language, doNotTranslate, templateSlug, tags, categories, createdAt, updatedAt, publishedAt, body) matches the published DB record |
|
||||
| ~~D2-6~~ | ~~SidecarRoundtrip invariant~~ | ~~media.allium:198~~ | **Resolved:** 2 tests added — full roundtrip (write sidecar, parse via `Sidecar.parse_document/1`, assert all fields match DB) + nil conditional fields absent from parsed sidecar |
|
||||
| ~~D2-7~~ | ~~ConditionalPostFields: nil fields absent from frontmatter~~ | frontmatter.allium:398 | **Resolved:** test added — post with nil excerpt/author/language → those fields absent from published file, 3 refute assertions |
|
||||
| ~~D2-8~~ | ~~ConditionalMediaFields: nil fields absent from sidecar~~ | frontmatter.allium:417 | **Resolved:** added width/height assertions to nil-fields-absent test |
|
||||
| D2-9 | ~~max_posts_per_page 1..500 constraint~~ | metadata.allium:75-77 | **Resolved:** 5 tests added (0→1, -5→1, 1000→500, nil→50, non-numeric→50) |
|
||||
| D2-10 | ~~SandboxedExecution: restricted capabilities blocked~~ | script.allium:84-88 | **Resolved:** 6 tests added (os.execute, os.rename, io.open for write, require, dofile, loadlib all blocked) |
|
||||
| D2-11 | ~~TransformToastBudget enforcement~~ | script.allium:251-258 | **Resolved:** 2 tests already existed (per-script cap + total budget) |
|
||||
| D2-12 | ~~ProgressThrottled: 250ms throttle~~ | task.allium:110-113 | **Resolved:** 2 tests added (rapid reports within 250ms dropped; value 1.0 bypasses throttle) |
|
||||
| D2-13 | ~~archived→draft transition~~ | post.allium:121 | **Resolved:** 4 tests already existed (unarchive→draft, restores content from disk, rejects non-archived, not_found) |
|
||||
| D2-14 | ~~archived→published transition~~ | post.allium:122 | **Resolved:** test already existed (publish_post republishes archived posts without losing body or published_at) |
|
||||
| D2-15 | ~~AppNoopNotifier: app writes don't produce notification rows~~ | cli_sync.allium:64-68 | **Resolved:** test added (post create, media import, metadata update do not produce notification rows) |
|
||||
| D2-16 | ~~ValidateMedia rule~~ | media_processing.allium:318-343 | **Resolved:** `validate_media/1` implemented in media.ex, 5 tests added (healthy, missing_binary, missing_sidecar, orphan, linked_not_orphan) |
|
||||
| D2-17 | ~~ContentHashSkipsUnchanged during reindex~~ | embedding.allium:199-202 | **Resolved:** 2 tests already existed (sync_post skips when hash matches; index_unindexed skips unchanged) |
|
||||
|
||||
### D3. Partial Test Coverage (needs expansion)
|
||||
|
||||
| ID | Claim | Spec | Gap | Path |
|
||||
|---|---|---|---|---|
|
||||
| D3-1 | PublishPost: content=null after publish | post.allium:186 | Not explicitly tested | Add assertion |
|
||||
| D3-2 | PublishPost: old file deleted on path change | engine_side_effects.allium:73-74 | Not tested | Add test |
|
||||
| D3-3 | UpsertPostTranslation: do_not_translate guard | translation.allium:113 | Indirectly covered only | Add direct test |
|
||||
| D3-4 | PublishTemplate: Liquid validation prerequisite | template.allium:139 | Not tested as publish gate | Add test |
|
||||
| D3-5 | PublishScript: validation prerequisite | script.allium:181 | Not tested as publish gate | Add test |
|
||||
| D3-6 | ExecuteMacro failure degrades to empty | script.allium:199 | Returns error tuple, not empty | Fix code or update spec |
|
||||
| D3-7 | TemplateFrontmatter roundtrip | template.allium:53 | Slug verified, no full parse-back | Add roundtrip test |
|
||||
| D3-8 | DefaultCategories for fresh project | metadata.allium:60 | Defaults present after add, not verified fresh | Add fresh-project test |
|
||||
| D3-9 | FtsIncludesTranslations | translation.allium:178 | Tested for one language; expand | Test all stemmer languages |
|
||||
| D3-10 | PostCanonicalUrl format | post.allium:33-40 | Constructed in links test, not asserted as invariant | Add format assertion |
|
||||
| D3-11 | Slug generation: German transliteration | post.allium:14-22 | "Föö Bär" → "foo-bar-blog" tested; expand ä/ö/ü/ß/ÄÖÜ | Expand test |
|
||||
| D3-1 | ~~PublishPost: content=null after publish~~ | post.allium:186 | **Resolved:** `refute published.content` added to frontmatter roundtrip test |
|
||||
| D3-2 | ~~PublishPost: old file deleted on path change~~ | engine_side_effects.allium:73-74 | **Resolved:** test already existed (`publish_post deletes old file when file path changes` at posts_test.exs:284) |
|
||||
| D3-3 | ~~UpsertPostTranslation: do_not_translate guard~~ | translation.allium:113 | **Resolved:** direct test added (do_not_translate guard rejects translation upsert with changeset error) |
|
||||
| D3-4 | ~~PublishTemplate: Liquid validation prerequisite~~ | template.allium:139 | **Resolved:** tests already existed (publish_template rejects invalid Liquid syntax, create_and_publish rejects invalid Liquid) |
|
||||
| D3-5 | ~~PublishScript: validation prerequisite~~ | script.allium:181 | **Resolved:** tests already existed (publish_script rejects invalid Lua syntax, create_and_publish rejects invalid Lua) |
|
||||
| D3-6 | ~~ExecuteMacro failure degrades to empty~~ | script.allium:199 | **Resolved:** `execute_macro` now returns `{:ok, ""}` on failure (was `{:error, reason}`), test updated, spec already correct |
|
||||
| D3-7 | ~~TemplateFrontmatter roundtrip~~ | template.allium:53 | **Resolved:** roundtrip test added (publish then parse back, compare all fields against DB record) |
|
||||
| D3-8 | ~~DefaultCategories for fresh project~~ | metadata.allium:60 | **Resolved:** test added (fresh project has article/aside/page/picture categories before any operations) |
|
||||
| D3-9 | ~~FtsIncludesTranslations~~ | translation.allium:178 | **Resolved:** test expanded (de/fr/es/it search terms all find the canonical post after reindex) |
|
||||
| D3-10 | ~~PostCanonicalUrl format~~ | post.allium:33-40 | **Resolved:** format test added (`/YYYY/MM/DD/slug/` via `LinksAndLanguages.post_path`) |
|
||||
| D3-11 | ~~Slug generation: German transliteration~~ | post.allium:14-22 | **Resolved:** 5 tests added (ß→ss, ö→o, ä→a, ü→u, mixed ÄÖÜäöüß→aouaouss) |
|
||||
|
||||
### D4. UI Test Coverage Gaps (whole-editor specs)
|
||||
|
||||
| ID | Spec | Covered | Not Covered |
|
||||
|---|---|---|---|
|
||||
| D4-1 | editor_media.allium | AI analysis, delete | Translate, replace file, link-to-post, translation CRUD, detect language |
|
||||
| D4-2 | editor_settings.allium | AI endpoints, airplane toggle, rebuild | Protected categories, MCP agents, style/theme, search filter, categories CRUD |
|
||||
| D4-3 | editor_chat.allium | Chat creation, pinned tab | API key screen, message rendering, input area, model selector, inline surfaces |
|
||||
| ~~D4-1~~ | ~~editor_media.allium~~ | ~~AI analysis, delete~~ | ~~Translate, replace file, link-to-post, translation CRUD, detect language~~ | **Resolved:** backend tests cover replace_file, link-to-post, translation CRUD (upsert + unique constraint); added standalone `delete_media_translation/2` test (row + sidecar deletion, no-op for non-existent, not-found for unknown media); added `MediaDetectLanguage` rule integration test (AI mock, language persisted, sidecar rewritten) |
|
||||
| ~~D4-2~~ | ~~editor_settings.allium~~ | ~~AI endpoints, airplane toggle, rebuild~~ | ~~Protected categories~~ (resolved D1-17), ~~MCP agents~~ (6 `mcp_rows` + 3 `toggle_mcp_agent` tests), ~~style/theme~~ (19 `build_style` + 4 select/change/apply/display tests), ~~search filter~~ (9 `build_settings` visibility tests), ~~categories CRUD~~ (7 `category_rows` + 2 update + 3 add + 2 save + 4 reset tests) | **Resolved:** 3 new test files (mcp_config_test.exs, style_editor_test.exs, settings_search_test.exs) + expanded managed_categories_test.exs cover all untested areas. Total 56 tests added across MCP agents, style/theme, search filter, and categories CRUD. |
|
||||
| D4-3 | ~~editor_chat.allium~~ | ~~Chat creation, pinned tab~~ | ~~API key screen, message rendering, input area, model selector, inline surfaces~~ | **Resolved:** 3 tests added — WelcomeScreen assertions (robot icon, title, 5 tips), CSP external image rewriting in markdown, chart inline surface rendering with series labels/values |
|
||||
| D4-4 | editor_script.allium | Editor layout, create defaults | Save, syntax check, run, delete |
|
||||
| D4-5 | editor_template.allium | Editor layout, create defaults | Save with validation, validate, delete with references |
|
||||
| D4-6 | editor_tags.allium | Sync/discover, merge | Cloud sizing, color picker, delete confirmation, create form |
|
||||
@@ -193,9 +193,10 @@ All reconciled to follow code. Specs must be self-consistent and match code.
|
||||
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 (B1-1, B1-2, B1-3, B1-4 resolved)
|
||||
4. ~~**B1-1 through B1-20**~~ — all resolved: chat inline surfaces, auto-translation, settings sections, style tab, published snapshot fields, rendering subsystem (new rendering.allium), 404.html, media translation modal, menu ops, language picker + confirm dialog, script/template publish actions, import + documentation tabs, metadata-diff entity types, task TTL eviction, discard-post-changes, replace-media-file
|
||||
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
|
||||
6. **D2-1 through D2-17** — untested rules
|
||||
7. **D3-1 through D3-11** — partial test coverage
|
||||
8. **B1-7 through B1-20** — minor code behaviors missing from spec
|
||||
9. **D4-1 through D4-7** — UI test coverage
|
||||
6. ~~**D2-1 through D2-17**~~ — all resolved: `max_posts_per_page` constraint, sandboxed execution, transform toast budget, progress throttle, archived→draft/published transitions, AppNoopNotifier, validate_media implementation+tests, content_hash skip on reindex
|
||||
7. ~~**D3-1 through D3-11**~~ — all resolved: content=null assertion, old-file-deletion, DNT guard, validation prerequisites (already tested), macro failure degrades to empty, template roundtrip, default categories, FTS multi-language, canonical URL format, German transliteration expansion
|
||||
8. ~~**B2-1 through B2-9**~~ — all resolved: editor_body resolver, single-post reimport, orphan import, dashboard data, missing-thumbnail regen, cache dir, stale-template prune, render labels, generation progress reporting
|
||||
9. **D4-1 through D4-3** — ~~UI test coverage~~ **Resolved (D4-1 via standalone delete_media_translation + MediaDetectLanguage tests; D4-2 via 3 new test files + expanded managed_categories — 56 tests added; D4-3 via WelcomeScreen/CSP/chart surface tests)**
|
||||
**D4-4 through D4-7** — remaining UI test coverage
|
||||
|
||||
@@ -349,6 +349,53 @@ defmodule BDS.Media do
|
||||
)
|
||||
end
|
||||
|
||||
@spec validate_media(String.t()) :: [%{media_id: String.t(), issue: String.t()}]
|
||||
def validate_media(project_id) do
|
||||
project = BDS.Projects.get_project!(project_id)
|
||||
data_dir = BDS.Projects.project_data_dir(project)
|
||||
|
||||
Repo.all(from m in Media, where: m.project_id == ^project_id)
|
||||
|> Enum.flat_map(fn media ->
|
||||
issues = []
|
||||
|
||||
binary_path = Path.join(data_dir, media.file_path)
|
||||
issues = if File.exists?(binary_path), do: issues, else: [%{media_id: media.id, issue: "missing_binary"} | issues]
|
||||
|
||||
sidecar_path = Path.join(data_dir, media.sidecar_path)
|
||||
issues = if File.exists?(sidecar_path), do: issues, else: [%{media_id: media.id, issue: "missing_sidecar"} | issues]
|
||||
|
||||
issues =
|
||||
if BDS.Media.FileOps.image_mime?(media.mime_type) do
|
||||
thumbnails = BDS.Media.Thumbnails.thumbnail_paths(media)
|
||||
|
||||
Enum.reduce([:small, :medium, :large, :ai], issues, fn size, acc ->
|
||||
thumb_path = Path.join(data_dir, Map.fetch!(thumbnails, size))
|
||||
|
||||
if File.exists?(thumb_path),
|
||||
do: acc,
|
||||
else: [%{media_id: media.id, issue: "missing_thumbnail_#{size}"} | acc]
|
||||
end)
|
||||
else
|
||||
issues
|
||||
end
|
||||
|
||||
issues =
|
||||
if BDS.Media.FileOps.image_mime?(media.mime_type) and File.exists?(binary_path) do
|
||||
case BDS.Media.FileOps.image_dimensions(binary_path, media.mime_type) do
|
||||
{nil, nil} -> [%{media_id: media.id, issue: "corrupted"} | issues]
|
||||
_ -> issues
|
||||
end
|
||||
else
|
||||
issues
|
||||
end
|
||||
|
||||
linked_posts = BDS.Media.Linking.list_linked_posts(media.id)
|
||||
issues = if linked_posts == [], do: [%{media_id: media.id, issue: "orphan"} | issues], else: issues
|
||||
|
||||
Enum.reverse(issues)
|
||||
end)
|
||||
end
|
||||
|
||||
defp log_thumbnail_error(:ok, _media_id), do: :ok
|
||||
|
||||
defp log_thumbnail_error({:error, reason}, media_id) do
|
||||
|
||||
@@ -71,7 +71,7 @@ defmodule BDS.Scripting do
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("execute_macro failed for project #{project_id}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
{:ok, ""}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -42,6 +42,56 @@ defmodule BDS.Scripts do
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@spec create_and_publish_script(attrs()) :: script_result()
|
||||
def create_and_publish_script(attrs) do
|
||||
project_id = attr(attrs, :project_id)
|
||||
title = attr(attrs, :title) || ""
|
||||
kind = attr(attrs, :kind)
|
||||
content = attr(attrs, :content) || ""
|
||||
|
||||
case validate_script(content) do
|
||||
:ok ->
|
||||
slug = unique_slug(project_id, Slug.slugify(title), "script")
|
||||
entrypoint = attr(attrs, :entrypoint) || default_entrypoint(kind)
|
||||
file_path = script_file_path(slug)
|
||||
now = Persistence.now_ms()
|
||||
|
||||
changeset =
|
||||
%Script{}
|
||||
|> Script.changeset(%{
|
||||
id: Ecto.UUID.generate(),
|
||||
project_id: project_id,
|
||||
slug: slug,
|
||||
title: title,
|
||||
kind: kind,
|
||||
entrypoint: entrypoint,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: file_path,
|
||||
status: :published,
|
||||
content: nil,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
})
|
||||
|
||||
with {:ok, script} <- Repo.insert(changeset) do
|
||||
full_path = full_file_path(script.project_id, file_path)
|
||||
File.mkdir_p!(Path.dirname(full_path))
|
||||
|
||||
:ok =
|
||||
Persistence.atomic_write(
|
||||
full_path,
|
||||
serialize_script_file(script, content)
|
||||
)
|
||||
|
||||
{:ok, script}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:invalid_script, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_script(String.t()) :: Script.t() | nil
|
||||
def get_script(script_id) do
|
||||
case Repo.get(Script, script_id) do
|
||||
|
||||
@@ -109,6 +109,54 @@ defmodule BDS.Templates do
|
||||
end
|
||||
end
|
||||
|
||||
@spec create_and_publish_template(attrs()) :: template_result()
|
||||
def create_and_publish_template(attrs) do
|
||||
project_id = attr(attrs, :project_id)
|
||||
title = attr(attrs, :title) || ""
|
||||
kind = attr(attrs, :kind)
|
||||
content = attr(attrs, :content) || ""
|
||||
|
||||
case validate_liquid(content) do
|
||||
:ok ->
|
||||
slug = unique_slug(project_id, Slug.slugify(title), "template")
|
||||
file_path = template_file_path(slug)
|
||||
now = Persistence.now_ms()
|
||||
|
||||
changeset =
|
||||
%Template{}
|
||||
|> Template.changeset(%{
|
||||
id: Ecto.UUID.generate(),
|
||||
project_id: project_id,
|
||||
slug: slug,
|
||||
title: title,
|
||||
kind: kind,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: file_path,
|
||||
status: :published,
|
||||
content: nil,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
})
|
||||
|
||||
with {:ok, template} <- Repo.insert(changeset) do
|
||||
full_path = full_file_path(template.project_id, file_path)
|
||||
File.mkdir_p!(Path.dirname(full_path))
|
||||
|
||||
:ok =
|
||||
Persistence.atomic_write(
|
||||
full_path,
|
||||
serialize_template_file(template, content)
|
||||
)
|
||||
|
||||
{:ok, template}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:invalid_liquid, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
@spec update_template(String.t(), attrs()) :: template_result() | {:error, :not_found}
|
||||
def update_template(template_id, attrs) do
|
||||
with %Template{} = template <- Repo.get(Template, template_id) do
|
||||
|
||||
@@ -20,6 +20,7 @@ use "./metadata.allium" as metadata -- Project config, categories, publi
|
||||
|
||||
-- Infrastructure
|
||||
use "./search.allium" as search -- FTS5 full-text search with Snowball stemming
|
||||
use "./rendering.allium" as rendering -- Template render assigns, filters, macros (preview + generation)
|
||||
use "./generation.allium" as generation -- Static site generation (sections, routes, hashing)
|
||||
use "./preview.allium" as preview -- Local HTTP preview server
|
||||
use "./publishing.allium" as publishing -- SSH upload (SCP / rsync)
|
||||
|
||||
@@ -130,9 +130,15 @@ surface MediaEditorSurface {
|
||||
@guarantee TranslationsSection
|
||||
-- Shown only when language is set.
|
||||
-- List of existing translations: flag emoji + language name + title.
|
||||
-- Per-translation actions: click to edit inline, refresh button, delete button.
|
||||
-- Per-translation actions: click to edit (opens modal), refresh button, delete button.
|
||||
-- "No translations" message when list is empty.
|
||||
|
||||
@guarantee TranslationEditModal
|
||||
-- Editing a translation opens a modal dialog ("Edit Translation"), not
|
||||
-- an inline form. Hidden language field plus Title, Alt Text, and
|
||||
-- Caption (textarea) inputs. Footer: Cancel and Save buttons.
|
||||
-- Save persists the translation; Cancel/close discards.
|
||||
|
||||
@guarantee LinkedPostsSection
|
||||
-- "Link to Post" button opens inline post picker overlay.
|
||||
-- List of currently linked posts with document icon. Click navigates to post tab.
|
||||
@@ -214,8 +220,9 @@ rule MediaLinkToPost {
|
||||
|
||||
rule MediaTranslationEdit {
|
||||
when: MediaTranslationEditClicked(media_id, language)
|
||||
-- Loads translation fields inline (title, alt, caption) for that language
|
||||
-- Edit in place, save persists to translated sidecar {path}.{lang}.meta
|
||||
-- Opens the "Edit Translation" modal pre-filled with the translation's
|
||||
-- title, alt, and caption for that language
|
||||
-- Save persists to DB + translated sidecar {path}.{lang}.meta; Cancel discards
|
||||
}
|
||||
|
||||
rule MediaTranslationRefresh {
|
||||
|
||||
@@ -148,6 +148,16 @@ surface DashboardSurface {
|
||||
-- Single-click: preview tab. Double-click: pin tab.
|
||||
}
|
||||
|
||||
rule ComputeDashboardData {
|
||||
when: DashboardRequested(project)
|
||||
-- stats: post counts grouped by status (total = sum of all statuses),
|
||||
-- plus media/image counts, total media size, tag and category counts.
|
||||
-- timeline: posts grouped by (year, month) of created_at, newest first,
|
||||
-- limited to the most recent config.dashboard_timeline_months with data.
|
||||
-- tag_cloud / category_cloud / recent_posts populated per their guarantees.
|
||||
ensures: Dashboard
|
||||
}
|
||||
|
||||
-- ─── Menu editor view ────────────────────────────────────────
|
||||
|
||||
-- Visual editor for the OPML navigation menu (meta/menu.opml).
|
||||
|
||||
@@ -19,6 +19,7 @@ value ScriptEditorView {
|
||||
entrypoint: String -- select: discovered Lua functions available as entrypoints
|
||||
enabled: Boolean -- checkbox
|
||||
content: String -- code editor content
|
||||
can_publish: Boolean -- true while status = draft
|
||||
created_at: String -- locale-formatted date
|
||||
updated_at: String -- locale-formatted date
|
||||
}
|
||||
@@ -37,13 +38,16 @@ surface ScriptEditorSurface {
|
||||
|
||||
provides:
|
||||
ScriptSaveRequested(editor.script_id)
|
||||
ScriptPublishRequested(editor.script_id)
|
||||
when editor.can_publish
|
||||
ScriptRunRequested(editor.script_id)
|
||||
ScriptCheckSyntaxRequested(editor.script_id)
|
||||
ScriptDeleteRequested(editor.script_id)
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Header bar with script title tab.
|
||||
-- Actions (right side): Save button, Run button,
|
||||
-- Actions (right side): Save button, Publish button (shown only when
|
||||
-- can_publish, i.e. status = draft), Run button,
|
||||
-- Check Syntax button, Delete button (danger style).
|
||||
|
||||
@guarantee MetadataRow
|
||||
@@ -72,6 +76,15 @@ rule ScriptSave {
|
||||
-- Entrypoint list re-discovered from Lua source after save
|
||||
}
|
||||
|
||||
rule ScriptPublish {
|
||||
when: ScriptPublishRequested(script_id)
|
||||
-- Only offered while the script is a draft (can_publish gate on the button)
|
||||
-- Validates Lua syntax first (publish gate); invalid source blocks publish
|
||||
-- Saves current draft fields, then publishes (status -> published)
|
||||
-- Writes the published script file to disk
|
||||
-- See script.allium PublishScript
|
||||
}
|
||||
|
||||
rule ScriptCheckSyntax {
|
||||
when: ScriptCheckSyntaxRequested(script_id)
|
||||
-- Validates Lua syntax without saving
|
||||
|
||||
@@ -18,6 +18,7 @@ value TemplateEditorView {
|
||||
kind: String -- select: post | list | not_found | partial
|
||||
enabled: Boolean -- checkbox
|
||||
content: String -- code editor content
|
||||
can_publish: Boolean -- true while status = draft
|
||||
created_at: String -- locale-formatted date
|
||||
updated_at: String -- locale-formatted date
|
||||
}
|
||||
@@ -35,12 +36,15 @@ surface TemplateEditorSurface {
|
||||
|
||||
provides:
|
||||
TemplateSaveRequested(editor.template_id)
|
||||
TemplatePublishRequested(editor.template_id)
|
||||
when editor.can_publish
|
||||
TemplateValidateRequested(editor.template_id)
|
||||
TemplateDeleteRequested(editor.template_id)
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Header bar with template title tab.
|
||||
-- Actions (right side): Save button, Validate button,
|
||||
-- Actions (right side): Save button, Publish button (shown only when
|
||||
-- can_publish, i.e. status = draft), Validate button,
|
||||
-- Delete button (danger style).
|
||||
|
||||
@guarantee MetadataRow
|
||||
@@ -67,6 +71,15 @@ rule TemplateSave {
|
||||
-- See engine_side_effects.allium UpdateTemplateSideEffects
|
||||
}
|
||||
|
||||
rule TemplatePublish {
|
||||
when: TemplatePublishRequested(template_id)
|
||||
-- Only offered while the template is a draft (can_publish gate on the button)
|
||||
-- Validates Liquid first (publish gate); invalid source blocks publish
|
||||
-- Saves current draft fields, then publishes (status -> published)
|
||||
-- Writes the published .liquid file to disk
|
||||
-- See template.allium PublishTemplate
|
||||
}
|
||||
|
||||
rule TemplateValidate {
|
||||
when: TemplateValidateRequested(template_id)
|
||||
-- Validates Liquid syntax without saving
|
||||
|
||||
@@ -24,7 +24,15 @@ surface GenerationRuntimeSurface {
|
||||
|
||||
provides:
|
||||
PageRenderRequested(template, context)
|
||||
GenerationProgressReported(current, total, label)
|
||||
GenerateSiteCompleted(generation)
|
||||
|
||||
@guarantee ProgressReporting
|
||||
-- Generation, reindex, and site-validation run as background tasks and
|
||||
-- emit count-based progress (current/total + label, e.g. "Processing
|
||||
-- N of M posts") via the task progress channel (see task.allium
|
||||
-- ReportProgress / ProgressThrottled). Multi-phase work (validation)
|
||||
-- maps each phase onto a fixed fraction of the 0.0..1.0 bar.
|
||||
}
|
||||
|
||||
value GenerationSection {
|
||||
@@ -121,10 +129,13 @@ rule GenerateCoreSectionPages {
|
||||
-- Atom feed
|
||||
ensures: FileGenerated("calendar.json")
|
||||
-- Post dates for calendar widget
|
||||
ensures: FileGenerated("404.html")
|
||||
-- Not-found page rendered from the not-found template
|
||||
for lang in generation.blog_languages - {generation.language}:
|
||||
ensures: FileGenerated(format("{lang}/index.html", lang: lang))
|
||||
ensures: FileGenerated(format("{lang}/feed.xml", lang: lang))
|
||||
ensures: FileGenerated(format("{lang}/atom.xml", lang: lang))
|
||||
ensures: FileGenerated(format("{lang}/404.html", lang: lang))
|
||||
}
|
||||
|
||||
-- Single section: one HTML page per published post
|
||||
|
||||
@@ -11,6 +11,7 @@ surface MediaControlSurface {
|
||||
provides:
|
||||
ImportMediaRequested(source_path, project, metadata)
|
||||
UpdateMediaRequested(media, changes)
|
||||
ReplaceMediaFileRequested(media, new_source_path)
|
||||
DeleteMediaRequested(media)
|
||||
UpsertMediaTranslationRequested(media, language, title, alt, caption)
|
||||
RebuildMediaFromFilesRequested(project)
|
||||
@@ -158,6 +159,28 @@ rule UpdateMedia {
|
||||
ensures: SearchIndexUpdated(media)
|
||||
}
|
||||
|
||||
rule ReplaceMediaFile {
|
||||
when: ReplaceMediaFileRequested(media, new_source_path)
|
||||
-- Replaces the binary at media.file_path with the new source file,
|
||||
-- keeping the same path/id. Sidecar metadata (title/alt/etc.) is preserved.
|
||||
let checksum = md5(read(new_source_path))
|
||||
-- Identical content (checksum = media.checksum): no-op, nothing rewritten.
|
||||
-- Otherwise the old file is backed up to {path}.bak (restored on failure,
|
||||
-- removed on success) and the row is updated from the new file:
|
||||
if checksum != media.checksum:
|
||||
ensures: media.checksum = checksum
|
||||
ensures: media.size = file_size(new_source_path)
|
||||
ensures: media.updated_at = now
|
||||
ensures: MediaDimensionsUpdated(media)
|
||||
-- width/height re-read from the new image
|
||||
ensures: SidecarWritten(media)
|
||||
if media.is_image:
|
||||
ensures: ThumbnailsRegenerated(media)
|
||||
-- Synchronous (awaited), not fire-and-forget
|
||||
ensures: SearchIndexUpdated(media)
|
||||
-- See engine_side_effects.allium ReplaceMediaFileSideEffects
|
||||
}
|
||||
|
||||
rule DeleteMedia {
|
||||
when: DeleteMediaRequested(media)
|
||||
ensures: not exists media
|
||||
|
||||
@@ -18,6 +18,7 @@ surface MediaProcessingControlSurface {
|
||||
TagMediaRequested(media, tags)
|
||||
DeleteMediaRequested(media)
|
||||
ValidateMediaRequested(project)
|
||||
RegenerateMissingThumbnailsRequested(project)
|
||||
}
|
||||
|
||||
surface MediaProcessingRuntimeSurface {
|
||||
@@ -131,6 +132,18 @@ rule GenerateThumbnails {
|
||||
)
|
||||
}
|
||||
|
||||
rule RegenerateMissingThumbnails {
|
||||
when: RegenerateMissingThumbnailsRequested(project)
|
||||
-- Maintenance sweep over the project's raster images (images, excluding SVG),
|
||||
-- oldest first. For each, any thumbnail file absent from disk is regenerated
|
||||
-- from the original binary; images with a full set are skipped.
|
||||
-- Runs as a background task reporting progress (current/total).
|
||||
-- Returns counts: processed, generated, failed.
|
||||
for media in project.media where is_image(media.mime_type) and not is_svg(media.mime_type):
|
||||
if any_thumbnail_missing(media):
|
||||
ensures: ThumbnailsRegenerated(media)
|
||||
}
|
||||
|
||||
-- Thumbnail generation algorithm
|
||||
value ThumbnailGeneration {
|
||||
-- 1. Load source image
|
||||
|
||||
@@ -14,6 +14,7 @@ surface MetadataMaintenanceSurface {
|
||||
provides:
|
||||
MetadataDiffRequested(project)
|
||||
RebuildFromFilesystemRequested(project, entity_type)
|
||||
RepairMetadataDiffItemRequested(project, direction, item)
|
||||
}
|
||||
|
||||
value DiffField {
|
||||
@@ -23,7 +24,8 @@ value DiffField {
|
||||
}
|
||||
|
||||
value DiffReport {
|
||||
entity_type: String -- post, media, script, template
|
||||
entity_type: String -- post, post_translation, media,
|
||||
-- media_translation, script, template, embedding
|
||||
entity_id: String
|
||||
differences: List<DiffField>
|
||||
}
|
||||
@@ -66,7 +68,26 @@ rule RunMetadataDiff {
|
||||
if matching.count = 0:
|
||||
ensures: OrphanReport.created(file_path: file)
|
||||
|
||||
-- Same pattern for media sidecar files, scripts, templates
|
||||
-- Same pattern for media sidecars (media), media translation sidecars
|
||||
-- (media_translation), scripts, templates, and embeddings.
|
||||
-- Embedding diffs compare the stored content_hash against the live post
|
||||
-- content to detect vectors that need recomputation.
|
||||
}
|
||||
|
||||
rule RepairMetadataDiffItem {
|
||||
when: RepairMetadataDiffItemRequested(project, direction, item)
|
||||
-- Resolves a single diff in one direction.
|
||||
-- direction = file_to_db (filesystem wins) | db_to_file (database wins)
|
||||
-- Dispatched per item.entity_type:
|
||||
-- project | categories | category_meta | publishing -> project metadata sync/flush
|
||||
-- post -> sync_post_from_file / rewrite_published_post
|
||||
-- post_translation -> sync_post_translation_from_file / rewrite_published_post_translation
|
||||
-- media -> sync_media_from_sidecar / sync_media_sidecar
|
||||
-- media_translation -> sync_media_translation_from_sidecar / sync_media_translation_sidecar
|
||||
-- script -> sync_script_from_file / sync_published_script_file
|
||||
-- template -> sync_template_from_file / sync_published_template_file
|
||||
-- embedding -> sync_post (file_to_db) / refresh_snapshot (db_to_file)
|
||||
-- Unknown entity_type or direction -> unsupported error.
|
||||
}
|
||||
|
||||
rule RebuildFromFilesystem {
|
||||
|
||||
@@ -55,6 +55,9 @@ surface PostControlSurface {
|
||||
PublishPostRequested(post)
|
||||
DeletePostRequested(post)
|
||||
ArchivePostRequested(post)
|
||||
DiscardPostChangesRequested(post)
|
||||
SyncPostFromFileRequested(post)
|
||||
ImportOrphanPostFileRequested(project, relative_path)
|
||||
}
|
||||
|
||||
surface PostFilePathSurface {
|
||||
@@ -100,6 +103,15 @@ entity Post {
|
||||
updated_at: Timestamp
|
||||
published_at: Timestamp?
|
||||
|
||||
-- Published snapshot: copy of title/content/tags/categories/excerpt as of
|
||||
-- the last publish. Used by changes_affect_published_content to decide when
|
||||
-- an edit reopens a published post to draft (see ReopenPublishedPost).
|
||||
published_title: String?
|
||||
published_content: String?
|
||||
published_tags: String?
|
||||
published_categories: String?
|
||||
published_excerpt: String?
|
||||
|
||||
-- Relationships
|
||||
translations: PostTranslation with canonical_post = this
|
||||
linked_media: PostMediaLink with post = this
|
||||
@@ -112,6 +124,10 @@ entity Post {
|
||||
-- Slug changes only allowed before first publish
|
||||
content_location: if status = published: file_path else: content
|
||||
-- Published: body in filesystem. Draft: body in DB field.
|
||||
editor_body: if content != null: content else: read_markdown_body(file_path)
|
||||
-- Resolver used by editors: prefer the in-DB draft content, else read
|
||||
-- the markdown body from the post's file. Empty string when neither
|
||||
-- exists. The same resolver applies to PostTranslation records.
|
||||
|
||||
transitions status {
|
||||
draft -> published
|
||||
@@ -218,6 +234,39 @@ rule ArchivePost {
|
||||
ensures: post.status = archived
|
||||
}
|
||||
|
||||
rule SyncPostFromFile {
|
||||
when: SyncPostFromFileRequested(post)
|
||||
requires: post.file_path != ""
|
||||
-- Single-post reimport: re-reads the post's own .md file and upserts the DB
|
||||
-- record from it (the filesystem is treated as truth for that one post).
|
||||
-- Re-syncs the post's link graph. Errors out if the file is missing.
|
||||
ensures: PostFieldsUpdated(post, parse_post_file(post.file_path))
|
||||
ensures: PostLinksUpdated(post)
|
||||
}
|
||||
|
||||
rule ImportOrphanPostFile {
|
||||
when: ImportOrphanPostFileRequested(project, relative_path)
|
||||
-- Imports a .md file that exists on disk but has no DB record (an orphan,
|
||||
-- e.g. surfaced by RunMetadataDiff). Rejects non-markdown / missing files.
|
||||
ensures:
|
||||
let new_post = Post.created(parse_post_file(relative_path))
|
||||
SearchIndexUpdated(new_post)
|
||||
}
|
||||
|
||||
rule DiscardPostChanges {
|
||||
when: DiscardPostChangesRequested(post)
|
||||
requires: post.file_path != ""
|
||||
-- Only posts with a published file on disk can be discarded;
|
||||
-- a never-published draft has no file version to restore.
|
||||
-- Re-reads the published .md file and upserts the DB record from it,
|
||||
-- discarding unsaved draft edits.
|
||||
ensures: post.content = null
|
||||
ensures: post.status = published
|
||||
ensures: PostLinksUpdated(post)
|
||||
ensures: SearchIndexUpdated(post)
|
||||
-- See engine_side_effects.allium DiscardPostChangesSideEffects
|
||||
}
|
||||
|
||||
-- File format axioms
|
||||
|
||||
invariant FrontmatterRoundtrip {
|
||||
|
||||
@@ -55,6 +55,10 @@ entity Project {
|
||||
-- Linux: $XDG_CONFIG_HOME/bds (default ~/.config/bds)
|
||||
-- Windows: %APPDATA%\\bds
|
||||
-- See PrivateArtifactsLiveInOsAppDir.
|
||||
cache_dir: private_dir + "/projects/" + id
|
||||
-- Per-project subtree of private_dir for that project's regenerable
|
||||
-- artifacts (embeddings index + sidecar). Overridable in tests via the
|
||||
-- :project_cache_root setting; never the repo or the project folder.
|
||||
}
|
||||
|
||||
surface ProjectSurface {
|
||||
|
||||
207
specs/rendering.allium
Normal file
207
specs/rendering.allium
Normal file
@@ -0,0 +1,207 @@
|
||||
-- allium: 1
|
||||
-- bDS Rendering Subsystem
|
||||
-- Scope: core — template rendering shared by preview and generation
|
||||
-- Distilled from: lib/bds/rendering/{filters,labels,links_and_languages,
|
||||
-- metadata,post_rendering,list_archive}.ex
|
||||
|
||||
-- The rendering subsystem turns a post/list/not-found record plus project
|
||||
-- metadata into the assigns consumed by a Liquid template. It is shared by the
|
||||
-- preview server (on-demand) and static site generation (published .md files).
|
||||
-- Rendering language is the CONTENT language (post/project), never the UI locale.
|
||||
|
||||
use "./template.allium" as template
|
||||
use "./template_context.allium" as template_context
|
||||
use "./i18n.allium" as i18n
|
||||
use "./post.allium" as post
|
||||
|
||||
-- ─── Custom Liquid filters ───────────────────────────────────
|
||||
|
||||
-- The three custom filters available in the Liquid subset (see
|
||||
-- template.allium LiquidFilterSubset). Applied during template rendering.
|
||||
|
||||
surface RenderFilterSurface {
|
||||
facing _: TemplateRenderer
|
||||
|
||||
provides:
|
||||
I18nFilterApplied(value, language)
|
||||
MarkdownFilterApplied(value, post_id, language)
|
||||
SlugifyFilterApplied(value)
|
||||
}
|
||||
|
||||
rule I18nFilter {
|
||||
when: I18nFilterApplied(value, language)
|
||||
-- {{ "Key" | i18n }} -> localized "render" domain string for `language`.
|
||||
-- Empty/blank input returns empty string.
|
||||
ensures: result = lgettext(language, "render", trim(value))
|
||||
}
|
||||
|
||||
rule SlugifyFilter {
|
||||
when: SlugifyFilterApplied(value)
|
||||
-- {{ title | slugify }} -> URL-friendly slug (same Slug.slugify as posts).
|
||||
ensures: result = slugify(value)
|
||||
}
|
||||
|
||||
rule MarkdownFilter {
|
||||
when: MarkdownFilterApplied(value, post_id, language)
|
||||
-- {{ body | markdown }} pipeline, in order:
|
||||
-- 1. Expand built-in [[...]] macros (see ExpandBuiltinMacros)
|
||||
-- 2. Convert markdown to HTML (Earmark; errors degrade to partial HTML)
|
||||
-- 3. Rewrite href/src URLs to canonical post/media paths (see RewriteUrls)
|
||||
ensures: result = rewritten_html
|
||||
}
|
||||
|
||||
-- ─── Built-in macros ─────────────────────────────────────────
|
||||
|
||||
-- Macros use double-bracket syntax [[name param="value" ...]], expanded in
|
||||
-- markdown before HTML conversion. NOT Liquid tags. Each renders a bundled
|
||||
-- macro template (macros/{name}) in an isolated Liquid subscope.
|
||||
|
||||
value BuiltinMacro {
|
||||
name: String -- youtube | vimeo | gallery | photo_archive | tag_cloud
|
||||
params: Map<String, String>
|
||||
}
|
||||
|
||||
surface BuiltinMacroSurface {
|
||||
context macro: BuiltinMacro
|
||||
|
||||
exposes:
|
||||
macro.name
|
||||
macro.params
|
||||
}
|
||||
|
||||
rule ExpandBuiltinMacros {
|
||||
when: MacroEncountered(macro, language, post_id)
|
||||
-- Unknown macro names are left verbatim in the source.
|
||||
if macro.name = "youtube" or macro.name = "vimeo":
|
||||
-- Embeds video by `id`; localized default title when title param absent.
|
||||
ensures: MacroTemplateRendered(format("macros/{name}", name: macro.name))
|
||||
if macro.name = "gallery":
|
||||
-- Image gallery from the post's linked image media (ordered by sort).
|
||||
-- `columns` clamped to 1..6 (default 3). Empty when no post_id/images.
|
||||
ensures: MacroTemplateRendered("macros/gallery")
|
||||
if macro.name = "photo_archive":
|
||||
-- Month-grouped image archive for the project (optional year/month filter).
|
||||
ensures: MacroTemplateRendered("macros/photo-archive")
|
||||
if macro.name = "tag_cloud":
|
||||
-- Weighted tag cloud from post tag counts; per-tag colours from Tag rows.
|
||||
ensures: MacroTemplateRendered("macros/tag-cloud")
|
||||
}
|
||||
|
||||
invariant MacroIsolation {
|
||||
-- Macro templates render in an isolated Liquid subscope so macro-local
|
||||
-- assigns never leak into the surrounding template context.
|
||||
}
|
||||
|
||||
-- ─── URL rewriting ───────────────────────────────────────────
|
||||
|
||||
rule RewriteUrls {
|
||||
when: RenderedHtmlProduced(html, canonical_post_paths, canonical_media_paths)
|
||||
-- Rewrites href= and src= attribute values in rendered HTML.
|
||||
-- External/special URLs (scheme:, //, #) are left untouched.
|
||||
-- Internal post references (/post/{slug}, /posts/{slug}, dated paths) map to
|
||||
-- the post's canonical dated path; query/fragment suffixes are preserved.
|
||||
-- Internal /media/YYYY/MM/{file} references map to canonical media paths.
|
||||
ensures: AttributesRewritten(html)
|
||||
}
|
||||
|
||||
-- ─── Links and languages ─────────────────────────────────────
|
||||
|
||||
value LinkContext {
|
||||
href: String
|
||||
title: String
|
||||
display_slug: String
|
||||
language: String
|
||||
}
|
||||
|
||||
rule ResolveLanguagePrefix {
|
||||
when: LanguagePrefixRequested(language, main_language)
|
||||
-- "" for the main language (and nil/blank); "/{language}" otherwise.
|
||||
if language = main_language:
|
||||
ensures: prefix = ""
|
||||
else:
|
||||
ensures: prefix = format("/{lang}", lang: language)
|
||||
}
|
||||
|
||||
rule CollectLinkContexts {
|
||||
when: LinkContextsRequested(project, post_id, direction)
|
||||
-- direction = incoming (backlinks) | outgoing.
|
||||
-- One LinkContext per linked post that still exists; missing targets dropped.
|
||||
-- href = canonical post path, language normalized to main when unset.
|
||||
ensures: List<LinkContext>
|
||||
}
|
||||
|
||||
-- ─── Render labels ───────────────────────────────────────────
|
||||
|
||||
value RenderLabels {
|
||||
-- Localized strings for rendered/preview output, resolved in the "render"
|
||||
-- gettext domain for the CONTENT language (not the UI locale). Includes
|
||||
-- taxonomy, backlinks, archive, pagination, calendar, search, not-found,
|
||||
-- and macro fallback labels. Month names resolved 1..12 per language.
|
||||
}
|
||||
|
||||
invariant LabelsUseContentLanguage {
|
||||
-- RenderLabels and the i18n filter resolve against the content/render
|
||||
-- language, consistent with i18n.allium's split-localization rule.
|
||||
}
|
||||
|
||||
-- ─── Post render assigns ─────────────────────────────────────
|
||||
|
||||
-- The full assigns map for a single post template, assembled from the post
|
||||
-- record (Post or PostTranslation) and project metadata.
|
||||
|
||||
value PostRenderAssigns {
|
||||
language: String
|
||||
language_prefix: String
|
||||
page_title: String?
|
||||
pico_stylesheet_href: String
|
||||
blog_languages: List<i18n/RenderLanguage>
|
||||
alternate_links: List<AlternateLink> -- hreflang alternates for translations
|
||||
menu_items: List<template_context/MenuItem>
|
||||
post_categories: List<String>
|
||||
post_tags: List<String>
|
||||
tag_color_by_name: Map<String, String?>
|
||||
backlinks: List<LinkContext>
|
||||
canonical_post_path_by_slug: Map<String, String>
|
||||
canonical_media_path_by_source_path: Map<String, String>
|
||||
post_data_json_by_id: String -- PostData JSON for client widgets
|
||||
post: template_context/PostContext -- includes incoming/outgoing links
|
||||
labels: RenderLabels
|
||||
calendar_initial_year: Integer?
|
||||
calendar_initial_month: Integer?
|
||||
}
|
||||
|
||||
value AlternateLink {
|
||||
language: String
|
||||
href: String
|
||||
}
|
||||
|
||||
rule BuildPostAssigns {
|
||||
when: PostAssignsRequested(project, assigns)
|
||||
-- Loads the post/translation record, renders its markdown body (macros +
|
||||
-- HTML + URL rewrite), collects incoming/outgoing links, and resolves all
|
||||
-- metadata-derived assigns (menu, languages, alternates, tag colours,
|
||||
-- calendar bounds, labels) for the post's language.
|
||||
ensures: PostRenderAssigns
|
||||
}
|
||||
|
||||
rule BuildNotFoundAssigns {
|
||||
when: NotFoundAssignsRequested(project, assigns)
|
||||
-- Assigns for the 404 page: page_title defaults to "404", no alternates,
|
||||
-- but shares language/menu/blog_languages/stylesheet/labels with posts.
|
||||
-- Consumed by the not-found template (see generation.allium 404.html).
|
||||
ensures: NotFoundRenderAssigns
|
||||
}
|
||||
|
||||
rule BuildListAssigns {
|
||||
when: ListAssignsRequested(project, assigns)
|
||||
-- Assigns for list/archive pages (home, category, tag, date archives):
|
||||
-- paginated post list plus shared metadata-derived assigns.
|
||||
ensures: ListRenderAssigns
|
||||
}
|
||||
|
||||
invariant SharedRenderPathForPreviewAndGeneration {
|
||||
-- Preview and generation produce identical HTML for the same input because
|
||||
-- both build assigns through this subsystem and render via the same Liquid
|
||||
-- subset. They differ only in content SOURCE (see preview.allium
|
||||
-- PreviewDraftOverlay and generation.allium GenerationPublishedOnly).
|
||||
}
|
||||
@@ -36,6 +36,7 @@ surface TaskRuntimeSurface {
|
||||
TaskWorkCompleted(task)
|
||||
TaskWorkFailed(task, error_message)
|
||||
ProgressReported(task, value, message)
|
||||
FinishedTaskEvictionDue()
|
||||
}
|
||||
|
||||
surface TaskSurface {
|
||||
@@ -54,6 +55,8 @@ surface TaskSurface {
|
||||
config {
|
||||
max_concurrent: Integer = 3
|
||||
progress_throttle: Duration = 250.milliseconds
|
||||
finished_task_ttl: Duration = 1.hour
|
||||
recent_finished_limit: Integer = 10
|
||||
}
|
||||
|
||||
invariant MaxConcurrency {
|
||||
@@ -112,6 +115,23 @@ invariant ProgressThrottled {
|
||||
-- At most one progress event per 250ms per task
|
||||
}
|
||||
|
||||
invariant FinishedTaskRetention {
|
||||
-- The status snapshot surfaces only the most recent finished tasks:
|
||||
-- completed/failed/cancelled tasks beyond config.recent_finished_limit
|
||||
-- (newest first) are not shown.
|
||||
let finished = Tasks where status in {completed, failed, cancelled}
|
||||
-- At most config.recent_finished_limit finished tasks are reported.
|
||||
}
|
||||
|
||||
rule EvictFinishedTasks {
|
||||
when: FinishedTaskEvictionDue()
|
||||
-- Periodic sweep (every config.finished_task_ttl). A finished task whose
|
||||
-- finished_at is older than config.finished_task_ttl is dropped from state.
|
||||
for task in Tasks where status in {completed, failed, cancelled}:
|
||||
if now - task.finished_at >= config.finished_task_ttl:
|
||||
ensures: not exists task
|
||||
}
|
||||
|
||||
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
|
||||
rule RegisterExternalTask {
|
||||
when: RegisterExternalTaskRequested(name)
|
||||
|
||||
@@ -175,6 +175,12 @@ rule RebuildTemplatesFromFiles {
|
||||
let parsed = parse_template_file(file)
|
||||
ensures: Template.created(parsed)
|
||||
-- or updated if slug already exists
|
||||
-- Prune stale published templates: a published Template whose file_path is
|
||||
-- neither among the scanned files nor present on disk is deleted, clearing
|
||||
-- its references first (posts' template_slug, tags' post_template_slug).
|
||||
for template in project.templates where status = published and file_path != "":
|
||||
if template.file_path not in scanned_files and not file_exists(template.file_path):
|
||||
ensures: not exists template
|
||||
}
|
||||
|
||||
-- Exact Liquid subset required (distilled from bundled starter templates)
|
||||
|
||||
@@ -13,6 +13,37 @@ defmodule BDS.CliSyncTest do
|
||||
:ok
|
||||
end
|
||||
|
||||
test "app-side writes do not produce notification rows (AppNoopNotifier)", %{} do
|
||||
existing_before_create = Repo.aggregate(BDS.CliSync.Notification, :count)
|
||||
|
||||
# Perform a few app-side operations — post create, media import, metadata
|
||||
# update — none should leave a notification row behind.
|
||||
temp_dir =
|
||||
Path.join(System.tmp_dir!(), "bds-app-noop-#{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: "AppNoop", data_path: temp_dir})
|
||||
|
||||
{:ok, _post} =
|
||||
BDS.Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "App-Created",
|
||||
content: "body"
|
||||
})
|
||||
|
||||
source_path = Path.join(temp_dir, "image.png")
|
||||
File.write!(source_path, "fake png")
|
||||
{:ok, _media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||
|
||||
{:ok, _metadata} =
|
||||
BDS.Metadata.update_project_metadata(project.id, %{name: "Renamed"})
|
||||
|
||||
existing_after = Repo.aggregate(BDS.CliSync.Notification, :count)
|
||||
assert existing_after == existing_before_create
|
||||
end
|
||||
|
||||
test "cli mutations are written to db_notifications, processed on file change, and marked seen" do
|
||||
assert {:ok, notification} = CliSync.cli_mutation_performed("post", "post-1", :updated)
|
||||
assert notification.from_cli == true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
defmodule BDS.CSM013BangRenderingTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias BDS.Rendering
|
||||
alias BDS.Rendering.PostRendering
|
||||
alias BDS.Rendering.TemplateSelection
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategoriesTest do
|
||||
|
||||
alias BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories
|
||||
|
||||
defp socket_with_assigns(extra \\ %{}) do
|
||||
%Phoenix.LiveView.Socket{assigns: Map.merge(%{__changed__: %{}, workbench: nil}, extra)}
|
||||
end
|
||||
|
||||
describe "protected_category?/1" do
|
||||
test "returns true for article, aside, page, picture" do
|
||||
assert ManagedCategories.protected_category?("article")
|
||||
@@ -18,6 +22,362 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategoriesTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "category_rows/1" do
|
||||
test "returns a row per category" do
|
||||
metadata = %{
|
||||
categories: ["article", "aside", "page", "picture", "notes"],
|
||||
category_settings: %{
|
||||
"article" => %{"title" => "Articles", "render_in_lists" => true, "show_title" => true},
|
||||
"notes" => %{"title" => "My Notes", "render_in_lists" => true, "show_title" => false}
|
||||
}
|
||||
}
|
||||
|
||||
rows = ManagedCategories.category_rows(metadata)
|
||||
assert length(rows) == 5
|
||||
end
|
||||
|
||||
test "each row has expected keys" do
|
||||
metadata = %{
|
||||
categories: ["article"],
|
||||
category_settings: %{"article" => %{"title" => "Articles"}}
|
||||
}
|
||||
|
||||
rows = ManagedCategories.category_rows(metadata)
|
||||
row = hd(rows)
|
||||
assert Map.has_key?(row, :name)
|
||||
assert Map.has_key?(row, :title)
|
||||
assert Map.has_key?(row, :render_in_lists)
|
||||
assert Map.has_key?(row, :show_title)
|
||||
assert Map.has_key?(row, :post_template_slug)
|
||||
assert Map.has_key?(row, :list_template_slug)
|
||||
assert Map.has_key?(row, :protected?)
|
||||
end
|
||||
|
||||
test "maps title from category_settings" do
|
||||
metadata = %{
|
||||
categories: ["article", "notes"],
|
||||
category_settings: %{
|
||||
"article" => %{"title" => "Articles"},
|
||||
"notes" => %{"title" => "My Notes"}
|
||||
}
|
||||
}
|
||||
|
||||
rows = ManagedCategories.category_rows(metadata)
|
||||
article = Enum.find(rows, &(&1.name == "article"))
|
||||
assert article.title == "Articles"
|
||||
|
||||
notes = Enum.find(rows, &(&1.name == "notes"))
|
||||
assert notes.title == "My Notes"
|
||||
end
|
||||
|
||||
test "falls back to category name when no title in settings" do
|
||||
rows = ManagedCategories.category_rows(%{
|
||||
categories: ["custom-cat"],
|
||||
category_settings: %{}
|
||||
})
|
||||
row = hd(rows)
|
||||
assert row.title == "custom-cat"
|
||||
end
|
||||
|
||||
test "marks protected categories" do
|
||||
rows = ManagedCategories.category_rows(%{
|
||||
categories: ["article", "notes"],
|
||||
category_settings: %{}
|
||||
})
|
||||
article = Enum.find(rows, &(&1.name == "article"))
|
||||
notes = Enum.find(rows, &(&1.name == "notes"))
|
||||
assert article.protected?
|
||||
refute notes.protected?
|
||||
end
|
||||
|
||||
test "applies default render_in_lists and show_title when not in settings" do
|
||||
rows = ManagedCategories.category_rows(%{
|
||||
categories: ["custom"],
|
||||
category_settings: %{}
|
||||
})
|
||||
row = hd(rows)
|
||||
assert row.render_in_lists == true
|
||||
assert row.show_title == true
|
||||
end
|
||||
|
||||
test "default template slugs are empty strings" do
|
||||
rows = ManagedCategories.category_rows(%{
|
||||
categories: ["custom"],
|
||||
category_settings: %{}
|
||||
})
|
||||
row = hd(rows)
|
||||
assert row.post_template_slug == ""
|
||||
assert row.list_template_slug == ""
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_new_category/3" do
|
||||
test "sets settings_editor_new_category assign" do
|
||||
socket = socket_with_assigns()
|
||||
reload = fn s, _wb ->
|
||||
send(self(), {:reloaded, s})
|
||||
s
|
||||
end
|
||||
|
||||
ManagedCategories.update_new_category(socket, "my-cat", reload)
|
||||
|
||||
assert_received {:reloaded, updated}
|
||||
assert updated.assigns.settings_editor_new_category == "my-cat"
|
||||
end
|
||||
|
||||
test "defaults to empty string when nil" do
|
||||
socket = socket_with_assigns()
|
||||
reload = fn s, _wb ->
|
||||
send(self(), {:reloaded, s})
|
||||
s
|
||||
end
|
||||
|
||||
ManagedCategories.update_new_category(socket, nil, reload)
|
||||
|
||||
assert_received {:reloaded, updated}
|
||||
assert updated.assigns.settings_editor_new_category == ""
|
||||
end
|
||||
end
|
||||
|
||||
describe "add_category/3" do
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
temp_dir =
|
||||
Path.join(System.tmp_dir!(), "bds-managed-cat-#{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: "AddCategory", data_path: temp_dir})
|
||||
|
||||
%{project: project}
|
||||
end
|
||||
|
||||
test "adds a new category via Metadata", %{project: project} do
|
||||
socket = socket_with_assigns(%{
|
||||
projects: %{active_project_id: project.id},
|
||||
settings_editor_new_category: "test-category"
|
||||
})
|
||||
|
||||
reload = fn s, _wb ->
|
||||
send(self(), :reloaded)
|
||||
s
|
||||
end
|
||||
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
|
||||
|
||||
ManagedCategories.add_category(socket, reload, append_output)
|
||||
|
||||
assert_received :reloaded
|
||||
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
|
||||
assert "test-category" in meta.categories
|
||||
end
|
||||
|
||||
test "clears new_category input after successful add", %{project: project} do
|
||||
socket = socket_with_assigns(%{
|
||||
projects: %{active_project_id: project.id},
|
||||
settings_editor_new_category: "test-category"
|
||||
})
|
||||
|
||||
reload = fn s, _wb -> s end
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
|
||||
|
||||
result = ManagedCategories.add_category(socket, reload, append_output)
|
||||
assert result.assigns.settings_editor_new_category == ""
|
||||
end
|
||||
|
||||
test "shows error for empty category name", %{project: project} do
|
||||
socket = socket_with_assigns(%{
|
||||
projects: %{active_project_id: project.id},
|
||||
settings_editor_new_category: ""
|
||||
})
|
||||
|
||||
reload = fn s, _wb -> s end
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind ->
|
||||
send(self(), :error_appended)
|
||||
socket
|
||||
end
|
||||
|
||||
ManagedCategories.add_category(socket, reload, append_output)
|
||||
|
||||
assert_received :error_appended
|
||||
end
|
||||
end
|
||||
|
||||
describe "save_category/4" do
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
temp_dir =
|
||||
Path.join(System.tmp_dir!(), "bds-managed-cat-#{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: "SaveCategory", data_path: temp_dir})
|
||||
|
||||
%{project: project}
|
||||
end
|
||||
|
||||
test "saves category settings via Metadata", %{project: project} do
|
||||
socket = socket_with_assigns(%{projects: %{active_project_id: project.id}})
|
||||
|
||||
params = %{
|
||||
"category" => "article",
|
||||
"title" => "Articles",
|
||||
"render_in_lists" => "true",
|
||||
"show_title" => "true",
|
||||
"post_template_slug" => "",
|
||||
"list_template_slug" => ""
|
||||
}
|
||||
|
||||
reload = fn s, _wb ->
|
||||
send(self(), :reloaded)
|
||||
s
|
||||
end
|
||||
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind ->
|
||||
send(self(), :error_appended)
|
||||
socket
|
||||
end
|
||||
|
||||
ManagedCategories.save_category(socket, params, reload, append_output)
|
||||
|
||||
assert_received :reloaded
|
||||
refute_received :error_appended
|
||||
|
||||
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
|
||||
cat_settings = meta.category_settings["article"]
|
||||
assert cat_settings["title"] == "Articles"
|
||||
assert cat_settings["render_in_lists"] == true
|
||||
assert cat_settings["show_title"] == true
|
||||
end
|
||||
|
||||
test "saves template slug for a category", %{project: project} do
|
||||
socket = socket_with_assigns(%{projects: %{active_project_id: project.id}})
|
||||
|
||||
params = %{
|
||||
"category" => "article",
|
||||
"title" => "Articles",
|
||||
"render_in_lists" => "true",
|
||||
"show_title" => "true",
|
||||
"post_template_slug" => "my-post",
|
||||
"list_template_slug" => "my-list"
|
||||
}
|
||||
|
||||
reload = fn s, _wb -> s end
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
|
||||
|
||||
ManagedCategories.save_category(socket, params, reload, append_output)
|
||||
|
||||
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
|
||||
cat_settings = meta.category_settings["article"]
|
||||
assert cat_settings["post_template_slug"] == "my-post"
|
||||
assert cat_settings["list_template_slug"] == "my-list"
|
||||
end
|
||||
end
|
||||
|
||||
describe "reset_categories/3" do
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
temp_dir =
|
||||
Path.join(System.tmp_dir!(), "bds-managed-cat-#{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: "ResetCategories", data_path: temp_dir})
|
||||
|
||||
BDS.Metadata.add_category(project.id, "custom-1")
|
||||
BDS.Metadata.add_category(project.id, "custom-2")
|
||||
|
||||
%{project: project}
|
||||
end
|
||||
|
||||
test "removes non-protected categories and restores defaults", %{project: project} do
|
||||
assert {:ok, before} = BDS.Metadata.get_project_metadata(project.id)
|
||||
assert "custom-1" in before.categories
|
||||
assert "custom-2" in before.categories
|
||||
|
||||
socket = socket_with_assigns(%{
|
||||
projects: %{active_project_id: project.id},
|
||||
settings_editor_new_category: "dirty"
|
||||
})
|
||||
|
||||
reload = fn s, _wb ->
|
||||
send(self(), :reloaded)
|
||||
s
|
||||
end
|
||||
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
|
||||
|
||||
ManagedCategories.reset_categories(socket, reload, append_output)
|
||||
|
||||
assert_received :reloaded
|
||||
|
||||
assert {:ok, after_meta} = BDS.Metadata.get_project_metadata(project.id)
|
||||
refute "custom-1" in after_meta.categories
|
||||
refute "custom-2" in after_meta.categories
|
||||
|
||||
assert "article" in after_meta.categories
|
||||
assert "aside" in after_meta.categories
|
||||
assert "page" in after_meta.categories
|
||||
assert "picture" in after_meta.categories
|
||||
end
|
||||
|
||||
test "preserves protected categories during reset", %{project: project} do
|
||||
socket = socket_with_assigns(%{
|
||||
projects: %{active_project_id: project.id},
|
||||
settings_editor_new_category: ""
|
||||
})
|
||||
|
||||
reload = fn s, _wb -> s end
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
|
||||
|
||||
ManagedCategories.reset_categories(socket, reload, append_output)
|
||||
|
||||
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
|
||||
assert "article" in meta.categories
|
||||
assert "aside" in meta.categories
|
||||
end
|
||||
|
||||
test "clears new_category input after reset", %{project: project} do
|
||||
socket = socket_with_assigns(%{
|
||||
projects: %{active_project_id: project.id},
|
||||
settings_editor_new_category: "dirty"
|
||||
})
|
||||
|
||||
reload = fn s, _wb -> s end
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
|
||||
|
||||
result = ManagedCategories.reset_categories(socket, reload, append_output)
|
||||
assert result.assigns.settings_editor_new_category == ""
|
||||
end
|
||||
|
||||
test "restores default category settings after reset", %{project: project} do
|
||||
socket = socket_with_assigns(%{
|
||||
projects: %{active_project_id: project.id},
|
||||
settings_editor_new_category: ""
|
||||
})
|
||||
|
||||
reload = fn s, _wb -> s end
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
|
||||
|
||||
ManagedCategories.reset_categories(socket, reload, append_output)
|
||||
|
||||
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
|
||||
article = meta.category_settings["article"]
|
||||
assert article["title"] == "article"
|
||||
assert article["render_in_lists"] == true
|
||||
assert article["show_title"] == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "remove_category/4" do
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
@@ -35,7 +395,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategoriesTest do
|
||||
end
|
||||
|
||||
test "rejects deletion of protected category with error output", %{project: project} do
|
||||
socket = %{assigns: %{projects: %{active_project_id: project.id}, workbench: nil}}
|
||||
socket = socket_with_assigns(%{projects: %{active_project_id: project.id}})
|
||||
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind ->
|
||||
send(self(), :error_appended)
|
||||
@@ -53,7 +413,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategoriesTest do
|
||||
end
|
||||
|
||||
test "rejects deletion of all protected categories", %{project: project} do
|
||||
socket = %{assigns: %{projects: %{active_project_id: project.id}, workbench: nil}}
|
||||
socket = socket_with_assigns(%{projects: %{active_project_id: project.id}})
|
||||
|
||||
for cat <- ["article", "aside", "page", "picture"] do
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind ->
|
||||
@@ -75,12 +435,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategoriesTest do
|
||||
test "allows deletion of non-protected category via Metadata.remove_category", %{
|
||||
project: project
|
||||
} do
|
||||
socket = %{
|
||||
assigns: %{
|
||||
projects: %{active_project_id: project.id},
|
||||
workbench: nil
|
||||
}
|
||||
}
|
||||
socket = socket_with_assigns(%{projects: %{active_project_id: project.id}})
|
||||
|
||||
BDS.Metadata.add_category(project.id, "test-cat")
|
||||
assert {:ok, meta} = BDS.Metadata.get_project_metadata(project.id)
|
||||
|
||||
82
test/bds/desktop/mcp_config_test.exs
Normal file
82
test/bds/desktop/mcp_config_test.exs
Normal file
@@ -0,0 +1,82 @@
|
||||
defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfigTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias BDS.Desktop.ShellLive.SettingsEditor.MCPConfig
|
||||
|
||||
describe "mcp_rows/0" do
|
||||
test "returns 7 agent rows" do
|
||||
rows = MCPConfig.mcp_rows()
|
||||
assert length(rows) == 7
|
||||
end
|
||||
|
||||
test "has correct agent order" do
|
||||
rows = MCPConfig.mcp_rows()
|
||||
ids = Enum.map(rows, & &1.id)
|
||||
assert ids == [:claude_code, :claude_desktop, :github_copilot, :gemini_cli, :opencode, :mistral_vibe, :openai_codex]
|
||||
end
|
||||
|
||||
test "Claude Code and GitHub Copilot are supported" do
|
||||
rows = MCPConfig.mcp_rows()
|
||||
claude = Enum.find(rows, &(&1.id == :claude_code))
|
||||
copilot = Enum.find(rows, &(&1.id == :github_copilot))
|
||||
assert claude.supported?
|
||||
assert copilot.supported?
|
||||
end
|
||||
|
||||
test "other agents are not supported" do
|
||||
rows = MCPConfig.mcp_rows()
|
||||
unsupported = Enum.reject(rows, & &1.supported?)
|
||||
assert length(unsupported) == 5
|
||||
assert Enum.all?(unsupported, &(!&1.supported?))
|
||||
end
|
||||
|
||||
test "unsupported agents have nil config_path" do
|
||||
rows = MCPConfig.mcp_rows()
|
||||
unsupported = Enum.filter(rows, &(!&1.supported?))
|
||||
assert Enum.all?(unsupported, &is_nil(&1.config_path))
|
||||
end
|
||||
|
||||
test "unsupported agents have configured? false" do
|
||||
rows = MCPConfig.mcp_rows()
|
||||
unsupported = Enum.filter(rows, &(!&1.supported?))
|
||||
assert Enum.all?(unsupported, &(&1.configured? == false))
|
||||
end
|
||||
end
|
||||
|
||||
describe "toggle_mcp_agent/4" do
|
||||
test "unsupported agent appends not-supported error" do
|
||||
socket = %{assigns: %{workbench: nil}}
|
||||
reload = fn s, _wb -> send(self(), :reloaded); s end
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind ->
|
||||
send(self(), :error_appended)
|
||||
socket
|
||||
end
|
||||
|
||||
MCPConfig.toggle_mcp_agent(socket, "gemini_cli", reload, append_output)
|
||||
|
||||
assert_received :error_appended
|
||||
assert_received :reloaded
|
||||
end
|
||||
|
||||
test "unsupported agent does not touch AgentConfig" do
|
||||
socket = %{assigns: %{workbench: nil}}
|
||||
reload = fn s, _wb -> s end
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind -> socket end
|
||||
|
||||
assert MCPConfig.toggle_mcp_agent(socket, "opencode", reload, append_output) == socket
|
||||
end
|
||||
|
||||
test "unknown agent is treated as unsupported" do
|
||||
socket = %{assigns: %{workbench: nil}}
|
||||
reload = fn s, _wb -> s end
|
||||
append_output = fn _socket, _title, _msg, _nil, _kind ->
|
||||
send(self(), :error_appended)
|
||||
socket
|
||||
end
|
||||
|
||||
MCPConfig.toggle_mcp_agent(socket, "nonexistent_agent", reload, append_output)
|
||||
|
||||
assert_received :error_appended
|
||||
end
|
||||
end
|
||||
end
|
||||
114
test/bds/desktop/settings_search_test.exs
Normal file
114
test/bds/desktop/settings_search_test.exs
Normal file
@@ -0,0 +1,114 @@
|
||||
defmodule BDS.Desktop.ShellLive.SettingsEditor.SettingsSearchTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias BDS.Desktop.ShellLive.SettingsEditor
|
||||
|
||||
describe "build_settings/1 — search filter" do
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
temp_dir =
|
||||
Path.join(System.tmp_dir!(), "bds-settings-search-#{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: "SearchFilter", data_path: temp_dir})
|
||||
|
||||
%{project: project, temp_dir: temp_dir}
|
||||
end
|
||||
|
||||
defp base_assigns(project_id, temp_dir, query) do
|
||||
%{
|
||||
projects: %{active_project_id: project_id},
|
||||
current_project: %{data_path: temp_dir},
|
||||
settings_editor_search: query,
|
||||
settings_editor_project_draft: %{},
|
||||
settings_editor_editor_draft: %{},
|
||||
settings_editor_ai_draft: %{},
|
||||
settings_editor_publishing_draft: %{},
|
||||
current_tab: %{type: :settings, id: "settings"},
|
||||
tab_meta: %{}
|
||||
}
|
||||
end
|
||||
|
||||
test "empty query shows all sections visible", %{project: project, temp_dir: temp_dir} do
|
||||
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, ""))
|
||||
|
||||
assert result.project_visible?
|
||||
assert result.editor_visible?
|
||||
assert result.content_visible?
|
||||
assert result.ai_visible?
|
||||
assert result.technology_visible?
|
||||
assert result.publishing_visible?
|
||||
assert result.mcp_visible?
|
||||
assert result.data_visible?
|
||||
end
|
||||
|
||||
test "matching query shows only relevant sections", %{project: project, temp_dir: temp_dir} do
|
||||
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "ai"))
|
||||
|
||||
assert result.ai_visible?
|
||||
refute result.editor_visible?
|
||||
refute result.content_visible?
|
||||
refute result.publishing_visible?
|
||||
end
|
||||
|
||||
test "query 'publishing' shows publishing section only", %{project: project, temp_dir: temp_dir} do
|
||||
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "publishing"))
|
||||
|
||||
assert result.publishing_visible?
|
||||
refute result.editor_visible?
|
||||
refute result.ai_visible?
|
||||
refute result.technology_visible?
|
||||
refute result.mcp_visible?
|
||||
refute result.data_visible?
|
||||
end
|
||||
|
||||
test "query 'mcp' shows mcp section", %{project: project, temp_dir: temp_dir} do
|
||||
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "mcp"))
|
||||
|
||||
assert result.mcp_visible?
|
||||
refute result.editor_visible?
|
||||
refute result.ai_visible?
|
||||
end
|
||||
|
||||
test "query 'claude' matches mcp section", %{project: project, temp_dir: temp_dir} do
|
||||
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "claude"))
|
||||
|
||||
assert result.mcp_visible?
|
||||
end
|
||||
|
||||
test "query 'data' shows data section", %{project: project, temp_dir: temp_dir} do
|
||||
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "data"))
|
||||
|
||||
assert result.data_visible?
|
||||
end
|
||||
|
||||
test "query 'editor' shows editor section", %{project: project, temp_dir: temp_dir} do
|
||||
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "editor"))
|
||||
|
||||
assert result.editor_visible?
|
||||
end
|
||||
|
||||
test "no match query shows no sections", %{project: project, temp_dir: temp_dir} do
|
||||
result = SettingsEditor.build_settings(base_assigns(project.id, temp_dir, "zzzzz"))
|
||||
|
||||
refute result.project_visible?
|
||||
refute result.editor_visible?
|
||||
refute result.content_visible?
|
||||
refute result.ai_visible?
|
||||
refute result.technology_visible?
|
||||
refute result.publishing_visible?
|
||||
refute result.mcp_visible?
|
||||
refute result.data_visible?
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_settings/1 — returns nil without active project" do
|
||||
test "returns nil when active_project_id is nil" do
|
||||
assert SettingsEditor.build_settings(%{projects: %{active_project_id: nil}}) == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5279,6 +5279,101 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
run_git!(project_dir, ["commit", "-m", message])
|
||||
end
|
||||
|
||||
test "chat editor renders welcome screen with robot icon and tips when no messages exist" do
|
||||
html =
|
||||
render_component(
|
||||
&BDS.Desktop.ShellLive.ChatEditor.render/1,
|
||||
phase3_chat_editor_assigns()
|
||||
)
|
||||
|
||||
assert html =~ "🤖"
|
||||
assert html =~ "Welcome to the AI Assistant"
|
||||
assert html =~ "Search for posts about a specific topic"
|
||||
assert html =~ "Show a chart of posts published per month"
|
||||
assert html =~ "Compare my recent posts in a table"
|
||||
assert html =~ "Update metadata for posts or media"
|
||||
assert html =~ "Show post statistics by year in tabs with charts"
|
||||
end
|
||||
|
||||
test "chat editor rewrites external image URLs in markdown to text links" do
|
||||
{:safe, html} =
|
||||
BDS.Desktop.ShellLive.ChatEditor.markdown_html(
|
||||
""
|
||||
)
|
||||
|
||||
assert html =~ ~s(<a href="https://example.com/img.jpg")
|
||||
refute html =~ ~r/<img\b/
|
||||
end
|
||||
|
||||
test "chat editor renders chart inline surfaces from render_chart tool calls" do
|
||||
assert {:ok, conversation} = AI.start_chat(%{title: "Chart Chat", model: "gpt-4.1"})
|
||||
|
||||
now = Persistence.now_ms()
|
||||
|
||||
Repo.insert!(
|
||||
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
||||
conversation_id: conversation.id,
|
||||
role: :user,
|
||||
content: "Show a chart",
|
||||
created_at: now
|
||||
})
|
||||
)
|
||||
|
||||
Repo.insert!(
|
||||
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
||||
conversation_id: conversation.id,
|
||||
role: :assistant,
|
||||
content: "Here is the chart data.",
|
||||
tool_calls:
|
||||
Jason.encode!([
|
||||
%{
|
||||
"id" => "call-chart",
|
||||
"name" => "render_chart",
|
||||
"arguments" => %{
|
||||
"title" => "Posts per Month",
|
||||
"chart_type" => "bar",
|
||||
"series" => [%{"label" => "May", "value" => 5}, %{"label" => "Jun", "value" => 3}]
|
||||
}
|
||||
}
|
||||
]),
|
||||
created_at: now + 1
|
||||
})
|
||||
)
|
||||
|
||||
Repo.insert!(
|
||||
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
||||
conversation_id: conversation.id,
|
||||
role: :tool,
|
||||
tool_call_id: "call-chart",
|
||||
content:
|
||||
Jason.encode!(%{
|
||||
"type" => "chart",
|
||||
"title" => "Posts per Month",
|
||||
"chart_type" => "bar",
|
||||
"series" => [%{"label" => "May", "value" => 5}, %{"label" => "Jun", "value" => 3}]
|
||||
}),
|
||||
created_at: now + 2
|
||||
})
|
||||
)
|
||||
|
||||
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||
|
||||
html =
|
||||
render_click(view, "pin_sidebar_item", %{
|
||||
"route" => "chat",
|
||||
"id" => conversation.id,
|
||||
"title" => conversation.title,
|
||||
"subtitle" => conversation.model || "chat"
|
||||
})
|
||||
|
||||
assert html =~ ~s(data-testid="chat-inline-surface")
|
||||
assert html =~ "Posts per Month"
|
||||
assert html =~ "bar"
|
||||
assert html =~ "May"
|
||||
assert html =~ "Jun"
|
||||
assert length(:binary.matches(html, ~s(class="chat-surface-chart-row"))) == 2
|
||||
end
|
||||
|
||||
# Sends a message to the LiveView and blocks until it has been processed.
|
||||
# Uses a test ping/pong to guarantee the message was handled before returning.
|
||||
defp send_and_await(view, message) do
|
||||
|
||||
173
test/bds/desktop/style_editor_test.exs
Normal file
173
test/bds/desktop/style_editor_test.exs
Normal file
@@ -0,0 +1,173 @@
|
||||
defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditorTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias BDS.Desktop.ShellLive.SettingsEditor.StyleEditor
|
||||
|
||||
defp socket_with_assigns(extra \\ %{}) do
|
||||
%Phoenix.LiveView.Socket{assigns: Map.merge(%{__changed__: %{}, workbench: nil}, extra)}
|
||||
end
|
||||
|
||||
describe "build_style/1" do
|
||||
test "returns nil when no active project" do
|
||||
assigns = %{projects: %{active_project_id: nil}}
|
||||
assert StyleEditor.build_style(assigns) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_style/1 with real project" do
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
||||
temp_dir =
|
||||
Path.join(System.tmp_dir!(), "bds-style-editor-#{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: "StyleEditor", data_path: temp_dir})
|
||||
|
||||
%{project: project}
|
||||
end
|
||||
|
||||
test "returns map with themes, selected_theme, applied_theme, preview_mode, preview_url", %{project: project} do
|
||||
assigns = %{projects: %{active_project_id: project.id}}
|
||||
|
||||
result = StyleEditor.build_style(assigns)
|
||||
assert is_map(result)
|
||||
assert Map.has_key?(result, :themes)
|
||||
assert Map.has_key?(result, :selected_theme)
|
||||
assert Map.has_key?(result, :applied_theme)
|
||||
assert Map.has_key?(result, :preview_mode)
|
||||
assert Map.has_key?(result, :preview_url)
|
||||
end
|
||||
|
||||
test "includes 20 themes", %{project: project} do
|
||||
assigns = %{projects: %{active_project_id: project.id}}
|
||||
result = StyleEditor.build_style(assigns)
|
||||
assert length(result.themes) == 20
|
||||
end
|
||||
|
||||
test "each theme has required keys", %{project: project} do
|
||||
assigns = %{projects: %{active_project_id: project.id}}
|
||||
result = StyleEditor.build_style(assigns)
|
||||
theme = hd(result.themes)
|
||||
assert Map.has_key?(theme, :name)
|
||||
assert Map.has_key?(theme, :accent_color)
|
||||
assert Map.has_key?(theme, :light_bg_color)
|
||||
assert Map.has_key?(theme, :dark_bg_color)
|
||||
end
|
||||
|
||||
test "includes default, amber, and zinc themes", %{project: project} do
|
||||
assigns = %{projects: %{active_project_id: project.id}}
|
||||
result = StyleEditor.build_style(assigns)
|
||||
names = Enum.map(result.themes, & &1.name)
|
||||
assert "default" in names
|
||||
assert "amber" in names
|
||||
assert "zinc" in names
|
||||
end
|
||||
|
||||
test "preview_url includes theme and mode params", %{project: project} do
|
||||
assigns = %{projects: %{active_project_id: project.id}}
|
||||
result = StyleEditor.build_style(assigns)
|
||||
assert result.preview_url =~ "theme=default"
|
||||
assert result.preview_url =~ "mode=auto"
|
||||
assert result.preview_url =~ "127.0.0.1:4123/__style-preview"
|
||||
end
|
||||
|
||||
test "preview_url reflects selected_theme", %{project: project} do
|
||||
assigns = %{
|
||||
projects: %{active_project_id: project.id},
|
||||
style_editor_theme: "amber"
|
||||
}
|
||||
|
||||
result = StyleEditor.build_style(assigns)
|
||||
assert result.selected_theme == "amber"
|
||||
assert result.preview_url =~ "theme=amber"
|
||||
end
|
||||
|
||||
test "preview_url reflects preview_mode", %{project: project} do
|
||||
assigns = %{
|
||||
projects: %{active_project_id: project.id},
|
||||
style_editor_preview_mode: "dark"
|
||||
}
|
||||
|
||||
result = StyleEditor.build_style(assigns)
|
||||
assert result.preview_mode == "dark"
|
||||
assert result.preview_url =~ "mode=dark"
|
||||
end
|
||||
|
||||
test "all 20 theme names are strings", %{project: project} do
|
||||
assigns = %{projects: %{active_project_id: project.id}}
|
||||
result = StyleEditor.build_style(assigns)
|
||||
assert Enum.all?(result.themes, &is_binary(&1.name))
|
||||
end
|
||||
end
|
||||
|
||||
describe "theme_display_name/1" do
|
||||
test "replaces hyphens with spaces and capitalizes" do
|
||||
assert StyleEditor.theme_display_name("default") == "Default"
|
||||
assert StyleEditor.theme_display_name("amber") == "Amber"
|
||||
end
|
||||
|
||||
test "handles empty string" do
|
||||
assert StyleEditor.theme_display_name("") == ""
|
||||
end
|
||||
end
|
||||
|
||||
describe "select_style_theme/3" do
|
||||
test "updates style_editor_theme assign" do
|
||||
socket = socket_with_assigns()
|
||||
reload = fn s, _wb ->
|
||||
send(self(), {:reloaded, s})
|
||||
s
|
||||
end
|
||||
|
||||
StyleEditor.select_style_theme(socket, "amber", reload)
|
||||
|
||||
assert_received {:reloaded, updated_socket}
|
||||
assert updated_socket.assigns.style_editor_theme == "amber"
|
||||
end
|
||||
|
||||
test "defaults to default when nil" do
|
||||
socket = socket_with_assigns()
|
||||
reload = fn s, _wb ->
|
||||
send(self(), {:reloaded, s})
|
||||
s
|
||||
end
|
||||
|
||||
StyleEditor.select_style_theme(socket, nil, reload)
|
||||
|
||||
assert_received {:reloaded, updated_socket}
|
||||
assert updated_socket.assigns.style_editor_theme == "default"
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_style_preview_mode/3" do
|
||||
test "updates style_editor_preview_mode assign" do
|
||||
socket = socket_with_assigns()
|
||||
reload = fn s, _wb ->
|
||||
send(self(), {:reloaded, s})
|
||||
s
|
||||
end
|
||||
|
||||
StyleEditor.change_style_preview_mode(socket, "dark", reload)
|
||||
|
||||
assert_received {:reloaded, updated_socket}
|
||||
assert updated_socket.assigns.style_editor_preview_mode == "dark"
|
||||
end
|
||||
|
||||
test "defaults to auto when nil" do
|
||||
socket = socket_with_assigns()
|
||||
reload = fn s, _wb ->
|
||||
send(self(), {:reloaded, s})
|
||||
s
|
||||
end
|
||||
|
||||
StyleEditor.change_style_preview_mode(socket, nil, reload)
|
||||
|
||||
assert_received {:reloaded, updated_socket}
|
||||
assert updated_socket.assigns.style_editor_preview_mode == "auto"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -597,6 +597,60 @@ defmodule BDS.MediaTest do
|
||||
assert contents =~ "caption: \"Bildunterschrift\"\n---"
|
||||
end
|
||||
|
||||
test "delete_media_translation removes the translation row and sidecar file", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
source_path = Path.join(temp_dir, "sample.txt")
|
||||
File.write!(source_path, "hello media")
|
||||
|
||||
assert {:ok, media} =
|
||||
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||
|
||||
assert {:ok, de_translation} =
|
||||
BDS.Media.upsert_media_translation(media.id, "de", %{
|
||||
title: "Titel",
|
||||
alt: "Alt text",
|
||||
caption: "Bildunterschrift"
|
||||
})
|
||||
|
||||
assert {:ok, fr_translation} =
|
||||
BDS.Media.upsert_media_translation(media.id, "fr", %{
|
||||
title: "Titre",
|
||||
alt: "Texte alternatif",
|
||||
caption: "Légende"
|
||||
})
|
||||
|
||||
de_sidecar = Path.join(temp_dir, media.file_path <> ".de.meta")
|
||||
fr_sidecar = Path.join(temp_dir, media.file_path <> ".fr.meta")
|
||||
assert File.exists?(de_sidecar)
|
||||
assert File.exists?(fr_sidecar)
|
||||
|
||||
assert {:ok, true} = BDS.Media.delete_media_translation(media.id, "de")
|
||||
|
||||
assert Repo.get(BDS.Media.Translation, de_translation.id) == nil
|
||||
assert Repo.get(BDS.Media.Translation, fr_translation.id) != nil
|
||||
refute File.exists?(de_sidecar)
|
||||
assert File.exists?(fr_sidecar)
|
||||
end
|
||||
|
||||
test "delete_media_translation is a no-op for a non-existent translation before deleting known ones",
|
||||
%{project: project, temp_dir: temp_dir} do
|
||||
source_path = Path.join(temp_dir, "untitled.txt")
|
||||
File.write!(source_path, "hello")
|
||||
|
||||
assert {:ok, media} =
|
||||
BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||
|
||||
assert {:ok, false} = BDS.Media.delete_media_translation(media.id, "de")
|
||||
end
|
||||
|
||||
test "delete_media_translation returns {:error, :not_found} for unknown media", %{
|
||||
temp_dir: _temp_dir
|
||||
} do
|
||||
assert {:error, :not_found} = BDS.Media.delete_media_translation("does-not-exist", "de")
|
||||
end
|
||||
|
||||
test "UniqueMediaTranslation: a media has at most one translation per language", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
@@ -642,6 +696,85 @@ defmodule BDS.MediaTest do
|
||||
assert %{language: ["has already been taken"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "SidecarRoundtrip: writing then parsing a sidecar yields matching DB metadata", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
source_path = Path.join(temp_dir, "roundtrip.txt")
|
||||
File.write!(source_path, "hello media")
|
||||
|
||||
assert {:ok, media} =
|
||||
BDS.Media.import_media(%{
|
||||
project_id: project.id,
|
||||
source_path: source_path,
|
||||
title: "Roundtrip Title",
|
||||
alt: "Roundtrip Alt",
|
||||
caption: "Roundtrip Caption",
|
||||
author: "Tester",
|
||||
language: "en",
|
||||
tags: ["alpha", "beta"]
|
||||
})
|
||||
|
||||
sidecar_path = Path.join(temp_dir, media.sidecar_path)
|
||||
{:ok, contents} = File.read(sidecar_path)
|
||||
{:ok, parsed} = BDS.Sidecar.parse_document(contents)
|
||||
|
||||
assert parsed["title"] == media.title
|
||||
assert parsed["alt"] == media.alt
|
||||
assert parsed["caption"] == media.caption
|
||||
assert parsed["tags"] == media.tags
|
||||
assert parsed["id"] == media.id
|
||||
assert parsed["originalName"] == media.original_name
|
||||
assert parsed["mimeType"] == media.mime_type
|
||||
assert parsed["size"] == media.size
|
||||
assert parsed["author"] == media.author
|
||||
assert parsed["language"] == media.language
|
||||
assert parsed["createdAt"] == media.created_at
|
||||
assert parsed["updatedAt"] == media.updated_at
|
||||
end
|
||||
|
||||
test "SidecarRoundtrip: conditional fields are absent from parsed sidecar when nil", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
source_path = Path.join(temp_dir, "minimal.txt")
|
||||
File.write!(source_path, "hello media")
|
||||
|
||||
assert {:ok, media} =
|
||||
BDS.Media.import_media(%{
|
||||
project_id: project.id,
|
||||
source_path: source_path
|
||||
})
|
||||
|
||||
assert media.title == nil
|
||||
assert media.alt == nil
|
||||
assert media.caption == nil
|
||||
assert media.author == nil
|
||||
assert media.language == nil
|
||||
assert media.width == nil
|
||||
assert media.height == nil
|
||||
|
||||
sidecar_path = Path.join(temp_dir, media.sidecar_path)
|
||||
{:ok, contents} = File.read(sidecar_path)
|
||||
{:ok, parsed} = BDS.Sidecar.parse_document(contents)
|
||||
|
||||
refute Map.has_key?(parsed, "title")
|
||||
refute Map.has_key?(parsed, "alt")
|
||||
refute Map.has_key?(parsed, "caption")
|
||||
refute Map.has_key?(parsed, "author")
|
||||
refute Map.has_key?(parsed, "language")
|
||||
refute Map.has_key?(parsed, "width")
|
||||
refute Map.has_key?(parsed, "height")
|
||||
|
||||
assert Map.has_key?(parsed, "id")
|
||||
assert Map.has_key?(parsed, "originalName")
|
||||
assert Map.has_key?(parsed, "mimeType")
|
||||
assert Map.has_key?(parsed, "size")
|
||||
assert Map.has_key?(parsed, "tags")
|
||||
assert Map.has_key?(parsed, "createdAt")
|
||||
assert Map.has_key?(parsed, "updatedAt")
|
||||
end
|
||||
|
||||
defp errors_on(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
||||
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
|
||||
@@ -694,6 +827,93 @@ defmodule BDS.MediaTest do
|
||||
|> Image.open!()
|
||||
end
|
||||
|
||||
describe "validate_media (D2-16)" do
|
||||
test "reports no issues for healthy media with a post link", %{project: project, temp_dir: temp_dir} do
|
||||
source_path = Path.join(temp_dir, "sample.png")
|
||||
File.write!(source_path, sample_image_binary(".png"))
|
||||
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||
{:ok, post} = BDS.Posts.create_post(%{project_id: project.id, title: "Linked", content: "body"})
|
||||
{:ok, :linked} = BDS.Media.Linking.link_media_to_post(media.id, post.id)
|
||||
assert [] == BDS.Media.validate_media(project.id)
|
||||
end
|
||||
|
||||
test "reports missing binary file", %{project: project, temp_dir: temp_dir} do
|
||||
source_path = Path.join(temp_dir, "sample.txt")
|
||||
File.write!(source_path, "hello")
|
||||
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||
media_id = media.id
|
||||
|
||||
data_dir = BDS.Projects.project_data_dir(project)
|
||||
File.rm!(Path.join(data_dir, media.file_path))
|
||||
|
||||
issues = BDS.Media.validate_media(project.id)
|
||||
assert Enum.any?(issues, &(&1.media_id == media_id and &1.issue == "missing_binary"))
|
||||
end
|
||||
|
||||
test "reports missing sidecar", %{project: project, temp_dir: temp_dir} do
|
||||
source_path = Path.join(temp_dir, "sample.txt")
|
||||
File.write!(source_path, "hello")
|
||||
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||
media_id = media.id
|
||||
|
||||
data_dir = BDS.Projects.project_data_dir(project)
|
||||
File.rm!(Path.join(data_dir, media.sidecar_path))
|
||||
|
||||
issues = BDS.Media.validate_media(project.id)
|
||||
assert Enum.any?(issues, &(&1.media_id == media_id and &1.issue == "missing_sidecar"))
|
||||
end
|
||||
|
||||
test "reports orphan media (not linked to any post)", %{project: project, temp_dir: temp_dir} do
|
||||
source_path = Path.join(temp_dir, "sample.txt")
|
||||
File.write!(source_path, "hello")
|
||||
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||
media_id = media.id
|
||||
|
||||
issues = BDS.Media.validate_media(project.id)
|
||||
assert Enum.any?(issues, &(&1.media_id == media_id and &1.issue == "orphan"))
|
||||
end
|
||||
|
||||
test "does not report orphan when linked to a post", %{project: project, temp_dir: temp_dir} do
|
||||
source_path = Path.join(temp_dir, "sample.txt")
|
||||
File.write!(source_path, "hello")
|
||||
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
|
||||
|
||||
{:ok, post} = BDS.Posts.create_post(%{project_id: project.id, title: "Linked", content: "body"})
|
||||
{:ok, :linked} = BDS.Media.Linking.link_media_to_post(media.id, post.id)
|
||||
|
||||
issues = BDS.Media.validate_media(project.id)
|
||||
refute Enum.any?(issues, &(&1.issue == "orphan"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "MediaDetectLanguage rule" do
|
||||
test "updating language persists to the row and rewrites the sidecar", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
source_path = Path.join(temp_dir, "detect-me.txt")
|
||||
File.write!(source_path, "Bonjour tout le monde")
|
||||
|
||||
assert {:ok, media} =
|
||||
BDS.Media.import_media(%{
|
||||
project_id: project.id,
|
||||
source_path: source_path,
|
||||
title: "Entrée",
|
||||
caption: "Un délice culinaire"
|
||||
})
|
||||
|
||||
assert media.language == nil
|
||||
|
||||
assert {:ok, updated_media} = BDS.Media.update_media(media.id, %{language: "fr"})
|
||||
|
||||
assert updated_media.language == "fr"
|
||||
|
||||
data_dir = BDS.Projects.project_data_dir(project)
|
||||
sidecar = File.read!(Path.join(data_dir, updated_media.sidecar_path))
|
||||
assert sidecar =~ "language: fr\n"
|
||||
end
|
||||
end
|
||||
|
||||
defp assert_images_match!(left, right) do
|
||||
assert Image.shape(left) == Image.shape(right)
|
||||
|
||||
|
||||
@@ -242,6 +242,106 @@ defmodule BDS.MetadataTest do
|
||||
assert File.exists?(BDS.Embeddings.index_path(project.id))
|
||||
end
|
||||
|
||||
test "remove_category removes the category and its settings from state, files, and DB", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
# Add a category + settings first
|
||||
assert {:ok, metadata} = BDS.Metadata.add_category(project.id, "news")
|
||||
assert "news" in metadata.categories
|
||||
|
||||
assert {:ok, _metadata} =
|
||||
BDS.Metadata.update_category_settings(project.id, "news", %{
|
||||
render_in_lists: false,
|
||||
show_title: true,
|
||||
post_template_slug: "article"
|
||||
})
|
||||
|
||||
# Remove the category
|
||||
assert {:ok, updated} = BDS.Metadata.remove_category(project.id, "news")
|
||||
|
||||
# 1. Category removed from in-memory list
|
||||
refute "news" in updated.categories
|
||||
assert "article" in updated.categories
|
||||
assert "aside" in updated.categories
|
||||
assert "page" in updated.categories
|
||||
assert "picture" in updated.categories
|
||||
|
||||
# 2. Category settings removed from in-memory map
|
||||
refute Map.has_key?(updated.category_settings, "news")
|
||||
|
||||
# 3. Verify via get_project_metadata
|
||||
assert {:ok, loaded} = BDS.Metadata.get_project_metadata(project.id)
|
||||
refute "news" in loaded.categories
|
||||
refute Map.has_key?(loaded.category_settings, "news")
|
||||
|
||||
# 4. meta/categories.json rewritten without the removed category
|
||||
categories_path = Path.join([temp_dir, "meta", "categories.json"])
|
||||
assert ["article", "aside", "page", "picture"] =
|
||||
Jason.decode!(File.read!(categories_path))
|
||||
|
||||
# 5. meta/category-meta.json rewritten without the removed category's settings
|
||||
category_meta_path = Path.join([temp_dir, "meta", "category-meta.json"])
|
||||
category_meta = Jason.decode!(File.read!(category_meta_path))
|
||||
refute Map.has_key?(category_meta, "news")
|
||||
|
||||
# 6. DB settings updated
|
||||
cat_setting =
|
||||
BDS.Repo.get(BDS.Settings.Setting, "project:#{project.id}:categories")
|
||||
assert cat_setting != nil
|
||||
refute "news" in (cat_setting.value |> Jason.decode!() |> Map.get("categories", []))
|
||||
|
||||
meta_setting =
|
||||
BDS.Repo.get(BDS.Settings.Setting, "project:#{project.id}:category_meta")
|
||||
assert meta_setting != nil
|
||||
meta_categories = meta_setting.value |> Jason.decode!() |> Map.get("categories", %{})
|
||||
refute Map.has_key?(meta_categories, "news")
|
||||
end
|
||||
|
||||
test "remove_category is a no-op for non-existent category", %{
|
||||
project: project
|
||||
} do
|
||||
{:ok, metadata_before} = BDS.Metadata.get_project_metadata(project.id)
|
||||
|
||||
assert {:ok, metadata} = BDS.Metadata.remove_category(project.id, "nonexistent")
|
||||
assert metadata.categories == metadata_before.categories
|
||||
end
|
||||
|
||||
test "max_posts_per_page 0 is clamped to 1", %{project: project} do
|
||||
assert {:ok, metadata} =
|
||||
BDS.Metadata.update_project_metadata(project.id, %{max_posts_per_page: 0})
|
||||
|
||||
assert metadata.max_posts_per_page == 1
|
||||
end
|
||||
|
||||
test "max_posts_per_page negative is clamped to 1", %{project: project} do
|
||||
assert {:ok, metadata} =
|
||||
BDS.Metadata.update_project_metadata(project.id, %{max_posts_per_page: -5})
|
||||
|
||||
assert metadata.max_posts_per_page == 1
|
||||
end
|
||||
|
||||
test "max_posts_per_page above 500 is clamped to 500", %{project: project} do
|
||||
assert {:ok, metadata} =
|
||||
BDS.Metadata.update_project_metadata(project.id, %{max_posts_per_page: 1000})
|
||||
|
||||
assert metadata.max_posts_per_page == 500
|
||||
end
|
||||
|
||||
test "max_posts_per_page nil defaults to 50", %{project: project} do
|
||||
assert {:ok, metadata} =
|
||||
BDS.Metadata.update_project_metadata(project.id, %{max_posts_per_page: nil})
|
||||
|
||||
assert metadata.max_posts_per_page == 50
|
||||
end
|
||||
|
||||
test "max_posts_per_page non-numeric string defaults to 50", %{project: project} do
|
||||
assert {:ok, metadata} =
|
||||
BDS.Metadata.update_project_metadata(project.id, %{max_posts_per_page: "abc"})
|
||||
|
||||
assert metadata.max_posts_per_page == 50
|
||||
end
|
||||
|
||||
test "sync_project_metadata_from_filesystem materializes the canonical metadata files when missing",
|
||||
%{project: project, temp_dir: temp_dir} do
|
||||
meta_dir = Path.join(temp_dir, "meta")
|
||||
@@ -266,4 +366,13 @@ defmodule BDS.MetadataTest do
|
||||
refute File.exists?(Path.join(meta_dir, "category-meta.json.tmp"))
|
||||
refute File.exists?(Path.join(meta_dir, "publishing.json.tmp"))
|
||||
end
|
||||
|
||||
test "fresh project has default categories before any operations", %{project: project} do
|
||||
assert {:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
|
||||
assert "article" in metadata.categories
|
||||
assert "aside" in metadata.categories
|
||||
assert "page" in metadata.categories
|
||||
assert "picture" in metadata.categories
|
||||
assert length(metadata.categories) == 4
|
||||
end
|
||||
end
|
||||
|
||||
@@ -428,6 +428,30 @@ defmodule BDS.PostTranslationsTest do
|
||||
|
||||
defp wait_for_ai_tasks(count, attempts \\ 100)
|
||||
|
||||
test "do_not_translate guard prevents translation upsert via the API", %{
|
||||
project: project
|
||||
} do
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "DNT Guarded",
|
||||
content: "Body",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
assert {:ok, post} = Posts.update_post(post.id, %{do_not_translate: true})
|
||||
assert post.do_not_translate == true
|
||||
|
||||
assert {:error, changeset} =
|
||||
Posts.upsert_post_translation(post.id, "de", %{
|
||||
title: "Sollte fehlschlagen",
|
||||
content: "Inhalt"
|
||||
})
|
||||
|
||||
assert changeset.errors[:do_not_translate] ==
|
||||
{"cannot add translations when do_not_translate is true", []}
|
||||
end
|
||||
|
||||
defp wait_for_ai_tasks(_count, 0) do
|
||||
flunk("AI tasks did not reach expected state")
|
||||
end
|
||||
|
||||
@@ -188,6 +188,55 @@ defmodule BDS.PostsTest do
|
||||
refute File.exists?(full_path <> ".tmp")
|
||||
end
|
||||
|
||||
test "post frontmatter roundtrips: written fields parsed back match the database record", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
assert {:ok, post} =
|
||||
BDS.Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Full Roundtrip",
|
||||
excerpt: "Testing full roundtrip",
|
||||
content: "Body content survives roundtrip",
|
||||
tags: ["elixir", "testing"],
|
||||
categories: ["notes"],
|
||||
author: "Test Author",
|
||||
language: "de",
|
||||
template_slug: "article"
|
||||
})
|
||||
|
||||
assert {:ok, post} = BDS.Posts.update_post(post.id, %{do_not_translate: true})
|
||||
assert {:ok, published} = BDS.Posts.publish_post(post.id)
|
||||
assert published.status == :published
|
||||
refute published.content
|
||||
|
||||
full_path = Path.join(temp_dir, published.file_path)
|
||||
assert File.exists?(full_path)
|
||||
|
||||
file_contents = File.read!(full_path)
|
||||
assert {:ok, %{fields: fields, body: body}} = BDS.Frontmatter.parse_document(file_contents)
|
||||
|
||||
assert fields["id"] == published.id
|
||||
assert fields["title"] == "Full Roundtrip"
|
||||
assert fields["slug"] == "full-roundtrip"
|
||||
assert fields["excerpt"] == "Testing full roundtrip"
|
||||
assert fields["status"] == "published"
|
||||
assert fields["author"] == "Test Author"
|
||||
assert fields["language"] == "de"
|
||||
assert fields["doNotTranslate"] == true
|
||||
assert fields["templateSlug"] == "article"
|
||||
assert fields["tags"] == ["elixir", "testing"]
|
||||
assert fields["categories"] == ["notes"]
|
||||
assert is_integer(fields["createdAt"])
|
||||
assert fields["createdAt"] == published.created_at
|
||||
assert is_integer(fields["updatedAt"])
|
||||
assert fields["updatedAt"] == published.updated_at
|
||||
assert is_integer(fields["publishedAt"])
|
||||
assert fields["publishedAt"] == published.published_at
|
||||
|
||||
assert body == "Body content survives roundtrip"
|
||||
end
|
||||
|
||||
test "publish_post omits doNotTranslate from frontmatter when false", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
@@ -208,6 +257,31 @@ defmodule BDS.PostsTest do
|
||||
refute file_contents =~ "doNotTranslate"
|
||||
end
|
||||
|
||||
test "publish_post omits nil excerpt, author, language from frontmatter", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
assert {:ok, post} =
|
||||
BDS.Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Nil Optional Fields",
|
||||
content: "body"
|
||||
})
|
||||
|
||||
assert is_nil(post.excerpt)
|
||||
assert is_nil(post.author)
|
||||
assert is_nil(post.language)
|
||||
|
||||
assert {:ok, published} = BDS.Posts.publish_post(post.id)
|
||||
|
||||
full_path = Path.join(temp_dir, published.file_path)
|
||||
file_contents = File.read!(full_path)
|
||||
|
||||
refute file_contents =~ "excerpt:"
|
||||
refute file_contents =~ "author:"
|
||||
refute file_contents =~ "language:"
|
||||
end
|
||||
|
||||
test "publish_post deletes old file when file path changes", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
@@ -242,6 +316,45 @@ defmodule BDS.PostsTest do
|
||||
refute File.exists?(old_full)
|
||||
end
|
||||
|
||||
test "German ß transliterates to ss" do
|
||||
assert BDS.Slug.slugify("Straße") == "strasse"
|
||||
end
|
||||
|
||||
test "German ö becomes o via NFD decomposition" do
|
||||
assert BDS.Slug.slugify("Öl") == "ol"
|
||||
end
|
||||
|
||||
test "German ä becomes a via NFD decomposition" do
|
||||
assert BDS.Slug.slugify("Äpfel") == "apfel"
|
||||
end
|
||||
|
||||
test "German ü becomes u via NFD decomposition" do
|
||||
assert BDS.Slug.slugify("Über") == "uber"
|
||||
end
|
||||
|
||||
test "mixed German characters transliterate correctly" do
|
||||
assert BDS.Slug.slugify("ÄÖÜäöüß") == "aouaouss"
|
||||
end
|
||||
|
||||
test "canonical post URL follows the format /YYYY/MM/DD/slug/", %{
|
||||
project: project
|
||||
} do
|
||||
assert {:ok, post} =
|
||||
BDS.Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Canonical Format",
|
||||
content: "Body"
|
||||
})
|
||||
|
||||
created = BDS.Persistence.from_unix_ms!(post.created_at)
|
||||
year = Integer.to_string(created.year)
|
||||
month = String.pad_leading(Integer.to_string(created.month), 2, "0")
|
||||
day = String.pad_leading(Integer.to_string(created.day), 2, "0")
|
||||
|
||||
canonical = BDS.Rendering.LinksAndLanguages.post_path(post, nil)
|
||||
assert canonical == "/#{year}/#{month}/#{day}/canonical-format/"
|
||||
end
|
||||
|
||||
test "delete_post removes the database row and published markdown file when present" do
|
||||
temp_dir =
|
||||
Path.join(System.tmp_dir!(), "bds-post-delete-#{System.unique_integer([:positive])}")
|
||||
|
||||
@@ -106,7 +106,7 @@ defmodule BDS.Scripting.ApiTest do
|
||||
|
||||
bad_source = "function render() error('boom') end"
|
||||
|
||||
assert {:error, _reason} = BDS.Scripting.execute_macro(project.id, bad_source, [])
|
||||
assert {:ok, ""} = BDS.Scripting.execute_macro(project.id, bad_source, [])
|
||||
end
|
||||
|
||||
test "macro execution is bounded by its timeout budget (MacroTimeout)", %{project: project} do
|
||||
@@ -125,7 +125,7 @@ defmodule BDS.Scripting.ApiTest do
|
||||
)
|
||||
end)
|
||||
|
||||
assert {:error, :timeout} = result
|
||||
assert {:ok, ""} = result
|
||||
|
||||
elapsed_ms = div(elapsed_us, 1000)
|
||||
|
||||
|
||||
@@ -34,6 +34,42 @@ defmodule BDS.Scripting.LuaTest do
|
||||
assert_receive {:progress, %{"phase" => "write", "current" => 2, "total" => 2}}
|
||||
end
|
||||
|
||||
test "sandbox blocks os.execute" do
|
||||
source = "function main() os.execute('echo hacked') end"
|
||||
|
||||
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||
end
|
||||
|
||||
test "sandbox blocks os.rename" do
|
||||
source = "function main() os.rename('/etc/passwd', '/tmp/hacked') end"
|
||||
|
||||
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||
end
|
||||
|
||||
test "sandbox blocks io.open for writing" do
|
||||
source = "function main() io.open('/tmp/hacked', 'w') end"
|
||||
|
||||
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||
end
|
||||
|
||||
test "sandbox blocks require" do
|
||||
source = "function main() require('socket') end"
|
||||
|
||||
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||
end
|
||||
|
||||
test "sandbox blocks dofile" do
|
||||
source = "function main() dofile('/etc/hosts') end"
|
||||
|
||||
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||
end
|
||||
|
||||
test "sandbox blocks loadlib" do
|
||||
source = "function main() package.loadlib('libc.so', 'system') end"
|
||||
|
||||
assert {:error, _reason} = BDS.Scripting.execute(source, "main", [])
|
||||
end
|
||||
|
||||
test "enforces reduction limits" do
|
||||
source = "function main() while true do end end"
|
||||
|
||||
|
||||
@@ -155,6 +155,114 @@ defmodule BDS.ScriptsTest do
|
||||
assert published.status == :published
|
||||
end
|
||||
|
||||
test "create_and_publish_script creates a published script with file in one step", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
assert {:ok, script} =
|
||||
BDS.Scripts.create_and_publish_script(%{
|
||||
project_id: project.id,
|
||||
title: "Published Utility",
|
||||
kind: :utility,
|
||||
content: "function main() return 'ok' end"
|
||||
})
|
||||
|
||||
assert script.status == :published
|
||||
assert script.content == nil
|
||||
assert script.slug == "published-utility"
|
||||
assert script.enabled == true
|
||||
assert script.version == 1
|
||||
assert script.entrypoint == "main"
|
||||
assert script.file_path == "scripts/published-utility.lua"
|
||||
|
||||
full_path = Path.join(temp_dir, script.file_path)
|
||||
assert File.exists?(full_path)
|
||||
|
||||
contents = File.read!(full_path)
|
||||
assert contents =~ "---\nid: #{script.id}\n"
|
||||
assert contents =~ "projectId: #{project.id}\n"
|
||||
assert contents =~ "slug: published-utility\n"
|
||||
assert contents =~ "title: Published Utility\n"
|
||||
assert contents =~ "kind: utility\n"
|
||||
assert contents =~ "entrypoint: main\n"
|
||||
assert contents =~ "enabled: true\n"
|
||||
assert contents =~ "version: 1\n"
|
||||
assert contents =~ ~r/createdAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
|
||||
assert contents =~ ~r/updatedAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
|
||||
assert contents =~ "\n---\nfunction main() return 'ok' end\n"
|
||||
end
|
||||
|
||||
test "create_and_publish_script uses macro default entrypoint", %{
|
||||
project: project
|
||||
} do
|
||||
assert {:ok, script} =
|
||||
BDS.Scripts.create_and_publish_script(%{
|
||||
project_id: project.id,
|
||||
title: "Render Helper",
|
||||
kind: :macro,
|
||||
content: "function render() end"
|
||||
})
|
||||
|
||||
assert script.entrypoint == "render"
|
||||
assert script.slug == "render-helper"
|
||||
end
|
||||
|
||||
test "create_and_publish_script rejects invalid Lua syntax", %{project: project} do
|
||||
assert {:error, {:invalid_script, _reason}} =
|
||||
BDS.Scripts.create_and_publish_script(%{
|
||||
project_id: project.id,
|
||||
title: "Bad Script",
|
||||
kind: :utility,
|
||||
content: "function main( missing end"
|
||||
})
|
||||
end
|
||||
|
||||
test "create_and_publish_script deduplicates slug on title conflict", %{project: project} do
|
||||
assert {:ok, _first} =
|
||||
BDS.Scripts.create_and_publish_script(%{
|
||||
project_id: project.id,
|
||||
title: "Same Title",
|
||||
kind: :utility,
|
||||
content: "function main() return 'v1' end"
|
||||
})
|
||||
|
||||
assert {:ok, second} =
|
||||
BDS.Scripts.create_and_publish_script(%{
|
||||
project_id: project.id,
|
||||
title: "Same Title",
|
||||
kind: :utility,
|
||||
content: "function main() return 'v2' end"
|
||||
})
|
||||
|
||||
assert second.slug == "same-title-2"
|
||||
assert second.status == :published
|
||||
end
|
||||
|
||||
test "create_script deduplicates slug on title conflict (UniqueScriptSlug)", %{
|
||||
project: project
|
||||
} do
|
||||
assert {:ok, first} =
|
||||
BDS.Scripts.create_script(%{
|
||||
project_id: project.id,
|
||||
title: "Dup Slug",
|
||||
kind: :utility,
|
||||
content: "function main() return 'first' end"
|
||||
})
|
||||
|
||||
assert first.slug == "dup-slug"
|
||||
|
||||
assert {:ok, second} =
|
||||
BDS.Scripts.create_script(%{
|
||||
project_id: project.id,
|
||||
title: "Dup Slug",
|
||||
kind: :utility,
|
||||
content: "function main() return 'second' end"
|
||||
})
|
||||
|
||||
assert second.slug == "dup-slug-2"
|
||||
refute second.id == first.id
|
||||
end
|
||||
|
||||
test "rebuild_scripts_from_files recreates published scripts from disk", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
|
||||
@@ -552,4 +552,53 @@ defmodule BDS.SearchTest do
|
||||
assert "hi" in languages
|
||||
assert Enum.uniq(languages) == languages
|
||||
end
|
||||
|
||||
test "search_posts finds translation text in multiple languages after reindex", %{
|
||||
project: project
|
||||
} do
|
||||
assert {:ok, post} =
|
||||
BDS.Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Multi Lang",
|
||||
content: "root body",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
now = System.system_time(:second)
|
||||
languages = [{"de", "Hallo Welt"}, {"fr", "Bonjour le monde"}, {"es", "Hola mundo"}, {"it", "Ciao mondo"}]
|
||||
|
||||
for {lang, content} <- languages do
|
||||
Repo.query!(
|
||||
"""
|
||||
INSERT INTO post_translations (
|
||||
id, project_id, translation_for, language, title, excerpt, content, status,
|
||||
created_at, updated_at, published_at, file_path, checksum
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
Ecto.UUID.generate(),
|
||||
project.id,
|
||||
post.id,
|
||||
lang,
|
||||
"Title #{lang}",
|
||||
"Summary",
|
||||
content,
|
||||
"draft",
|
||||
now,
|
||||
now,
|
||||
nil,
|
||||
"",
|
||||
nil
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
assert :ok = BDS.Search.reindex_project(project.id)
|
||||
|
||||
for {_lang, content} <- languages do
|
||||
search_term = String.split(content) |> List.last()
|
||||
assert {:ok, results} = BDS.Search.search_posts(project.id, search_term, %{})
|
||||
assert Enum.map(results.posts, & &1.id) == [post.id]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -95,6 +95,34 @@ defmodule BDS.TasksTest do
|
||||
assert wait_for_task(third.id, &(&1.status == :completed)).status == :completed
|
||||
end
|
||||
|
||||
test "progress reports within 250ms throttle window are silently dropped" do
|
||||
assert {:ok, task} = BDS.Tasks.register_external_task("fast progress")
|
||||
|
||||
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter")
|
||||
assert wait_for_task(task.id, &(&1.progress == 0.25)).progress == 0.25
|
||||
|
||||
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "half")
|
||||
assert task_id = task.id
|
||||
# The 250ms throttle has not elapsed, so progress stays at 0.25.
|
||||
assert wait_for_task(task_id, & &1.progress == 0.25).progress == 0.25
|
||||
|
||||
on_exit(fn -> BDS.Tasks.complete_task(task.id) end)
|
||||
end
|
||||
|
||||
test "progress report with value 1.0 bypasses the throttle" do
|
||||
assert {:ok, task} = BDS.Tasks.register_external_task("completion progress")
|
||||
|
||||
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter")
|
||||
|
||||
# A completion report (1.0) must go through even if throttled.
|
||||
assert :ok = BDS.Tasks.report_progress(task.id, 1.0, "done")
|
||||
|
||||
assert wait_for_task(task.id, &(&1.progress == 1.0)).progress == 1.0
|
||||
assert wait_for_task(task.id, &(&1.message == "done")).message == "done"
|
||||
|
||||
on_exit(fn -> BDS.Tasks.complete_task(task.id) end)
|
||||
end
|
||||
|
||||
test "external tasks are registered as running and can report progress and complete" do
|
||||
assert {:ok, task} =
|
||||
BDS.Tasks.register_external_task("preview build", %{
|
||||
|
||||
@@ -488,6 +488,108 @@ defmodule BDS.TemplatesTest do
|
||||
assert template.updated_at == 202
|
||||
end
|
||||
|
||||
test "create_and_publish_template creates a published template with file in one step", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
assert {:ok, template} =
|
||||
BDS.Templates.create_and_publish_template(%{
|
||||
project_id: project.id,
|
||||
title: "Published Layout",
|
||||
kind: :list,
|
||||
content: "<section>{{ content }}</section>"
|
||||
})
|
||||
|
||||
assert template.status == :published
|
||||
assert template.content == nil
|
||||
assert template.slug == "published-layout"
|
||||
assert template.enabled == true
|
||||
assert template.version == 1
|
||||
assert template.file_path == "templates/published-layout.liquid"
|
||||
|
||||
full_path = Path.join(temp_dir, template.file_path)
|
||||
assert File.exists?(full_path)
|
||||
|
||||
contents = File.read!(full_path)
|
||||
assert contents =~ "---\nid: #{template.id}\n"
|
||||
assert contents =~ "projectId: #{project.id}\n"
|
||||
assert contents =~ "slug: published-layout\n"
|
||||
assert contents =~ "title: Published Layout\n"
|
||||
assert contents =~ "kind: list\n"
|
||||
assert contents =~ "enabled: true\n"
|
||||
assert contents =~ "version: 1\n"
|
||||
assert contents =~ ~r/createdAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
|
||||
assert contents =~ ~r/updatedAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
|
||||
assert contents =~ "\n---\n<section>{{ content }}</section>\n"
|
||||
end
|
||||
|
||||
test "create_and_publish_template rejects invalid Liquid syntax", %{project: project} do
|
||||
assert {:error, {:invalid_liquid, _reason}} =
|
||||
BDS.Templates.create_and_publish_template(%{
|
||||
project_id: project.id,
|
||||
title: "Bad Layout",
|
||||
kind: :list,
|
||||
content: "{% for item in items %}unclosed"
|
||||
})
|
||||
end
|
||||
|
||||
test "create_and_publish_template deduplicates slug on title conflict", %{project: project} do
|
||||
assert {:ok, _first} =
|
||||
BDS.Templates.create_and_publish_template(%{
|
||||
project_id: project.id,
|
||||
title: "Same Title",
|
||||
kind: :list,
|
||||
content: "<section>v1</section>"
|
||||
})
|
||||
|
||||
assert {:ok, second} =
|
||||
BDS.Templates.create_and_publish_template(%{
|
||||
project_id: project.id,
|
||||
title: "Same Title",
|
||||
kind: :list,
|
||||
content: "<section>v2</section>"
|
||||
})
|
||||
|
||||
assert second.slug == "same-title-2"
|
||||
assert second.status == :published
|
||||
end
|
||||
|
||||
test "template frontmatter roundtrips: written fields parsed back match the database record", %{
|
||||
project: project,
|
||||
temp_dir: temp_dir
|
||||
} do
|
||||
assert {:ok, template} =
|
||||
BDS.Templates.create_template(%{
|
||||
project_id: project.id,
|
||||
title: "Roundtrip Layout",
|
||||
kind: :list,
|
||||
content: "<section>{{ page_title }}</section>",
|
||||
enabled: true
|
||||
})
|
||||
|
||||
assert {:ok, published} = BDS.Templates.publish_template(template.id)
|
||||
|
||||
full_path = Path.join(temp_dir, published.file_path)
|
||||
assert File.exists?(full_path)
|
||||
|
||||
contents = File.read!(full_path)
|
||||
assert {:ok, %{fields: fields, body: body}} = BDS.Frontmatter.parse_document(contents)
|
||||
|
||||
assert fields["id"] == published.id
|
||||
assert fields["projectId"] == project.id
|
||||
assert fields["slug"] == "roundtrip-layout"
|
||||
assert fields["title"] == "Roundtrip Layout"
|
||||
assert fields["kind"] == "list"
|
||||
assert fields["enabled"] == true
|
||||
assert fields["version"] == 1
|
||||
assert is_integer(fields["createdAt"])
|
||||
assert fields["createdAt"] == published.created_at
|
||||
assert is_integer(fields["updatedAt"])
|
||||
assert fields["updatedAt"] == published.updated_at
|
||||
|
||||
assert body == "<section>{{ page_title }}</section>"
|
||||
end
|
||||
|
||||
test "rebuild_templates_from_files removes stale published default templates when no local template files exist",
|
||||
%{
|
||||
project: project
|
||||
|
||||
Reference in New Issue
Block a user