From cd998f24a9fab43ade45b496fa52eeecfe213629 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 23 Apr 2026 10:42:27 +0200 Subject: [PATCH] initial commit Co-authored-by: Copilot --- .formatter.exs | 4 + .gitignore | 10 + .vscode/settings.json | 6 + README.md | 109 +++++ config/config.exs | 19 + config/dev.exs | 4 + config/runtime.exs | 11 + config/test.exs | 8 + lib/bds.ex | 5 + lib/bds/application.ex | 15 + lib/bds/repo.ex | 5 + mix.exs | 37 ++ mix.lock | 11 + priv/repo/migrations/.keep | 1 + specs/action_patterns.allium | 127 +++++ specs/ai.allium | 217 +++++++++ specs/bds.allium | 84 ++++ specs/cli_sync.allium | 68 +++ specs/editor_chat.allium | 144 ++++++ specs/editor_media.allium | 234 +++++++++ specs/editor_misc.allium | 786 ++++++++++++++++++++++++++++++ specs/editor_post.allium | 317 ++++++++++++ specs/editor_script.allium | 96 ++++ specs/editor_settings.allium | 271 +++++++++++ specs/editor_tags.allium | 118 +++++ specs/editor_template.allium | 87 ++++ specs/embedding.allium | 226 +++++++++ specs/engine_side_effects.allium | 314 ++++++++++++ specs/frontmatter.allium | 394 +++++++++++++++ specs/generation.allium | 231 +++++++++ specs/git.allium | 166 +++++++ specs/i18n.allium | 54 +++ specs/layout.allium | 291 +++++++++++ specs/mcp.allium | 392 +++++++++++++++ specs/media.allium | 198 ++++++++ specs/media_processing.allium | 374 +++++++++++++++ specs/menu.allium | 56 +++ specs/metadata.allium | 125 +++++ specs/metadata_diff.allium | 81 ++++ specs/modals.allium | 251 ++++++++++ specs/post.allium | 234 +++++++++ specs/preview.allium | 108 +++++ specs/project.allium | 110 +++++ specs/publishing.allium | 140 ++++++ specs/schema.allium | 713 +++++++++++++++++++++++++++ specs/script.allium | 188 ++++++++ specs/search.allium | 118 +++++ specs/sidebar_views.allium | 799 +++++++++++++++++++++++++++++++ specs/tabs.allium | 247 ++++++++++ specs/tag.allium | 121 +++++ specs/task.allium | 124 +++++ specs/template.allium | 211 ++++++++ specs/template_context.allium | 308 ++++++++++++ specs/translation.allium | 171 +++++++ specs/ui_data_flow.allium | 203 ++++++++ test/bds_test.exs | 7 + test/test_helper.exs | 2 + 57 files changed, 9751 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/runtime.exs create mode 100644 config/test.exs create mode 100644 lib/bds.ex create mode 100644 lib/bds/application.ex create mode 100644 lib/bds/repo.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 priv/repo/migrations/.keep create mode 100644 specs/action_patterns.allium create mode 100644 specs/ai.allium create mode 100644 specs/bds.allium create mode 100644 specs/cli_sync.allium create mode 100644 specs/editor_chat.allium create mode 100644 specs/editor_media.allium create mode 100644 specs/editor_misc.allium create mode 100644 specs/editor_post.allium create mode 100644 specs/editor_script.allium create mode 100644 specs/editor_settings.allium create mode 100644 specs/editor_tags.allium create mode 100644 specs/editor_template.allium create mode 100644 specs/embedding.allium create mode 100644 specs/engine_side_effects.allium create mode 100644 specs/frontmatter.allium create mode 100644 specs/generation.allium create mode 100644 specs/git.allium create mode 100644 specs/i18n.allium create mode 100644 specs/layout.allium create mode 100644 specs/mcp.allium create mode 100644 specs/media.allium create mode 100644 specs/media_processing.allium create mode 100644 specs/menu.allium create mode 100644 specs/metadata.allium create mode 100644 specs/metadata_diff.allium create mode 100644 specs/modals.allium create mode 100644 specs/post.allium create mode 100644 specs/preview.allium create mode 100644 specs/project.allium create mode 100644 specs/publishing.allium create mode 100644 specs/schema.allium create mode 100644 specs/script.allium create mode 100644 specs/search.allium create mode 100644 specs/sidebar_views.allium create mode 100644 specs/tabs.allium create mode 100644 specs/tag.allium create mode 100644 specs/task.allium create mode 100644 specs/template.allium create mode 100644 specs/template_context.allium create mode 100644 specs/translation.allium create mode 100644 specs/ui_data_flow.allium create mode 100644 test/bds_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..539935c --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto, :ecto_sql], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f63ebe9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/_build/ +/cover/ +/deps/ +/doc/ +/.elixir_ls/ +/erl_crash.dump +/priv/data/*.db +/priv/data/*.db-shm +/priv/data/*.db-wal +*.ez \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a6888d0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "chat.tools.terminal.autoApprove": { + "mix": true, + "allium": true + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fddfa5 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# bDS2 + +bDS2 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace in [../bDS](/Users/gb/Projects/bDS). This repository is the new implementation baseline: Elixir for the application core, Ecto for persistence, and a desktop shell to be selected as the rewrite matures. + +The repository currently contains a minimal Elixir/OTP foundation plus a set of Allium specifications in [specs/](/Users/gb/Projects/bDS2/specs). Those specs should define application behavior, file contracts, and user-visible workflows without tying the rewrite to a specific implementation stack. + +## Scope + +The rewrite aims to preserve the product behavior of bDS while replacing the technical stack. + +Behaviour that should remain stable includes: + +- Offline-first editorial workflows. +- Filesystem-backed content with stable frontmatter, media sidecars, templates, scripts, and menu formats. +- Project, post, media, translation, tag, template, generation, preview, publishing, AI, and MCP workflows. +- Generated site output, search behavior, metadata synchronization, and rebuild behavior where those are part of the product contract. + +The following are intentionally not part of the behavioral contract: + +- The implementation language. +- Desktop container or UI framework. +- ORM choice. +- Internal state management, concurrency model, or runtime libraries. + +## Repository Layout + +- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition. +- [config/](/Users/gb/Projects/bDS2/config): Elixir and Ecto configuration. +- [lib/](/Users/gb/Projects/bDS2/lib): application bootstrap and shared runtime modules. +- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations. +- [specs/](/Users/gb/Projects/bDS2/specs): Allium specs distilled from the existing bDS product and being normalized for implementation-agnostic use. + +## macOS Development Setup + +This machine does not have Elixir installed yet, so start with the toolchain. + +### 1. Install Xcode Command Line Tools + +```bash +xcode-select --install +``` + +### 2. Install Homebrew + +If Homebrew is not already installed: + +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +``` + +### 3. Install Erlang, Elixir, and SQLite + +```bash +brew update +brew install erlang elixir sqlite +``` + +Verify the installation: + +```bash +elixir --version +mix --version +sqlite3 --version +``` + +### 4. Fetch Dependencies + +```bash +cd /Users/gb/Projects/bDS2 +mix deps.get +``` + +### 5. Create the Local Database + +```bash +mix ecto.create +mix ecto.migrate +``` + +### 6. Run Tests + +```bash +mix test +``` + +## Current Elixir Baseline + +The initial scaffold is intentionally small: + +- OTP application startup in [lib/bds/application.ex](/Users/gb/Projects/bDS2/lib/bds/application.ex). +- Ecto repository in [lib/bds/repo.ex](/Users/gb/Projects/bDS2/lib/bds/repo.ex). +- Environment config in [config/config.exs](/Users/gb/Projects/bDS2/config/config.exs), [config/dev.exs](/Users/gb/Projects/bDS2/config/dev.exs), [config/test.exs](/Users/gb/Projects/bDS2/config/test.exs), and [config/runtime.exs](/Users/gb/Projects/bDS2/config/runtime.exs). +- Basic test bootstrap in [test/](/Users/gb/Projects/bDS2/test). + +This is not yet the desktop application. It is the base runtime that future work will extend with the domain model, persistence layer, content pipelines, and desktop-facing boundaries. + +## Spec Hygiene + +When editing files in [specs/](/Users/gb/Projects/bDS2/specs): + +- Keep user-visible workflows, content formats, and compatibility rules explicit. +- Keep behavior that affects imported data, generated output, or persisted content. +- Remove references to Rust, TypeScript, Electron, React, specific crates, specific packages, and other implementation choices unless the choice itself is a product requirement. + +## Next Steps + +1. Translate the core content and project specs into Ecto schemas and migrations. +2. Choose the desktop shell and boundary architecture for the Elixir application. +3. Add executable tests that map directly to the Allium specifications. diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..6e6be1d --- /dev/null +++ b/config/config.exs @@ -0,0 +1,19 @@ +import Config + +config :bds, + ecto_repos: [BDS.Repo] + +config :bds, BDS.Repo, + database: Path.expand("../priv/data/bds_dev.db", __DIR__), + pool_size: 5, + stacktrace: true, + show_sensitive_data_on_connection_error: true + +config :bds, BDS.Application, + desktop_adapter: :pending_selection + +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +import_config "#{config_env()}.exs" \ No newline at end of file diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..4cb3a26 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,4 @@ +import Config + +config :bds, BDS.Repo, + pool_size: 5 \ No newline at end of file diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..f2f54e4 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,11 @@ +import Config + +if config_env() == :prod do + database_path = + System.get_env("BDS_DATABASE_PATH") || + Path.expand("../priv/data/bds_prod.db", __DIR__) + + config :bds, BDS.Repo, + database: database_path, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5") +end \ No newline at end of file diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..4ee2c83 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,8 @@ +import Config + +config :bds, BDS.Repo, + database: Path.expand("../priv/data/bds_test.db", __DIR__), + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: 5 + +config :logger, level: :warning \ No newline at end of file diff --git a/lib/bds.ex b/lib/bds.ex new file mode 100644 index 0000000..449da97 --- /dev/null +++ b/lib/bds.ex @@ -0,0 +1,5 @@ +defmodule BDS do + @moduledoc """ + Entry point for the bDS rewrite domain. + """ +end \ No newline at end of file diff --git a/lib/bds/application.ex b/lib/bds/application.ex new file mode 100644 index 0000000..51bb97f --- /dev/null +++ b/lib/bds/application.ex @@ -0,0 +1,15 @@ +defmodule BDS.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + BDS.Repo + ] + + opts = [strategy: :one_for_one, name: BDS.Supervisor] + Supervisor.start_link(children, opts) + end +end \ No newline at end of file diff --git a/lib/bds/repo.ex b/lib/bds/repo.ex new file mode 100644 index 0000000..8d4bc9f --- /dev/null +++ b/lib/bds/repo.ex @@ -0,0 +1,5 @@ +defmodule BDS.Repo do + use Ecto.Repo, + otp_app: :bds, + adapter: Ecto.Adapters.SQLite3 +end \ No newline at end of file diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..3af35fe --- /dev/null +++ b/mix.exs @@ -0,0 +1,37 @@ +defmodule BDS.MixProject do + use Mix.Project + + def project do + [ + app: :bds, + version: "0.1.0", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger], + mod: {BDS.Application, []} + ] + end + + defp deps do + [ + {:ecto_sql, "~> 3.13"}, + {:ecto_sqlite3, "~> 0.21"} + ] + end + + defp aliases do + [ + setup: ["deps.get", "ecto.setup"], + "ecto.setup": ["ecto.create", "ecto.migrate"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] + ] + end +end \ No newline at end of file diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..92fea1e --- /dev/null +++ b/mix.lock @@ -0,0 +1,11 @@ +%{ + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, + "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, +} diff --git a/priv/repo/migrations/.keep b/priv/repo/migrations/.keep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/priv/repo/migrations/.keep @@ -0,0 +1 @@ + diff --git a/specs/action_patterns.allium b/specs/action_patterns.allium new file mode 100644 index 0000000..ee4adc4 --- /dev/null +++ b/specs/action_patterns.allium @@ -0,0 +1,127 @@ +-- allium: 1 +-- bDS Action Patterns and Chains +-- Scope: cross-cutting (all waves) +-- Distilled from: PostEditor.tsx, MediaEditor.tsx, appStore.ts + +-- Cross-cutting patterns for AI operations, auto-translation, +-- drag-and-drop chains, and confirmation dialogs. + +use "./post.allium" as post +use "./media.allium" as media + +-- ─── External surfaces ────────────────────────────────────── + +surface UserAction { + provides: AISuggestionRequested(entity_type, entity_id) + provides: PostSaved(post_id) + provides: PostAutoTranslateCompleted(post_id, language) +} + +-- ─── AI operation gating ─────────────────────────────── + +invariant AIOperationGating { + -- All AI operations route through the active endpoint for the current mode. + -- See ai.allium AirplaneModeGating for endpoint selection logic. + -- If airplane_mode: use airplane endpoint (local model, e.g. Ollama/LM Studio) + -- If online: use online endpoint (cloud provider) + -- If active endpoint not configured: show toast, abort operation + -- Two model categories: + -- Title model: text analysis (suggestions, translation, language detect) + -- Image model: vision-capable (image analysis only) + -- Model selection: per-operation default from settings, + -- overrideable per-conversation in chat panel only +} + +-- ─── AI suggestions pattern ──────────────────────────── + +-- Shared flow used by PostAIAnalysis and MediaAIImageAnalysis. +-- See modals.allium AISuggestionsModal value type. + +rule AISuggestionFlow { + when: AISuggestionRequested(entity_type, entity_id) + -- 1. Check AIOperationGating (abort if offline and no local model) + -- 2. Send request to appropriate model: + -- Posts: title model, input = title + excerpt + first 2000 chars + -- Media: image model, input = 448x448 AI thumbnail JPEG + -- 3. Parse response into per-field suggestions + -- 4. Open AISuggestionsModal: + -- Each field: label, current value, suggested value, accept checkbox + -- Accept checkboxes default to true + -- Special case: post slug locked (no checkbox) if ever published + -- 5. On Confirm: apply only accepted fields to entity + -- Posts: triggers auto-save (3s timer reset) + -- Media: triggers explicit save + -- 6. On Cancel: discard all suggestions, no changes +} + +-- ─── Auto-translation chain ──────────────────────────── + +rule AutoTranslationChain { + when: PostSaved(post_id) + -- Gate: AIOperationGating + post.doNotTranslate must be false + -- Triggered after any post save (auto-save, manual Ctrl+S, or unmount) + -- For each configured blogLanguage missing a translation for this post: + -- 1. Enqueue background task: translate metadata (title, excerpt) + -- via title model + -- 2. Enqueue background task: translate content (full markdown) + -- via title model + -- 3. Create/update translation record in DB + -- Tasks: sequential per language, parallel across languages + -- Progress visible in Tasks panel +} + +rule MediaMetadataTranslationCascade { + when: PostAutoTranslateCompleted(post_id, language) + -- After a post translation completes for a given language: + -- For each media item linked to this post: + -- If media has source language set + -- and no translation exists for {language}: + -- Enqueue background task: translate media metadata + -- (title, alt, caption) via title model + -- Creates translated sidecar file: {path}.{lang}.meta +} + +-- ─── Drag-and-drop image chain ───────────────────────── + +-- Full chain when image file is dropped on post editor body. +-- See editor_post.allium PostDragDropImage for trigger rule. + +-- Synchronous steps (user waits): +-- 1. importMedia(file) -> new media record + file copy + base sidecar +-- 2. generateThumbnails(media) -> async start (small/medium/large/ai) +-- 3. linkMediaToPost(media, post) -> update sidecar linkedPostIds +-- 4. insertMarkdownImage(cursor) -> insert ![](bds-media://id) at cursor + +-- Background steps (non-blocking, results auto-applied): +-- 5. If AI available: aiImageAnalysis(media) +-- Uses image model on 448x448 AI thumbnail +-- Results auto-applied to media metadata (NO modal for drag-drop, +-- unlike manual Quick Action which shows AISuggestionsModal) +-- Triggers sidecar rewrite +-- 6. If auto-translate enabled (post.doNotTranslate=false): +-- For each blogLanguage: translateMediaMetadata(media, lang) +-- Creates translated sidecar files + +-- ─── Confirmation dialog patterns ────────────────────── + +-- Four distinct patterns used across the application: + +-- Pattern 1: System confirm dialog +-- Simple yes/no system dialog, no custom UI +-- Used by: PostDelete, PostDiscard, TemplateDelete (when references exist) + +-- Pattern 2: ConfirmDeleteModal (custom modal with reference info) +-- Shows entity name, reference counts, linked entity list +-- Two buttons: Cancel, Delete (destructive red style) +-- Used by: MediaDelete (shows linked posts), TagDelete (shows post count) + +-- Pattern 3: ConfirmDialog (custom modal for non-delete confirmations) +-- Shows description of action and consequences +-- Two buttons: Cancel, Confirm +-- Used by: TagMerge ("Merge N tags into {target}? Cannot be undone.") + +-- Pattern 4: No confirmation (immediate execution) +-- Action executes on click, no dialog +-- Used by: all Rebuild operations, ScriptDelete, MenuItemDelete, +-- MetadataDiff per-field sync, ImportExecute, MediaTranslationDelete, +-- MediaUnlink diff --git a/specs/ai.allium b/specs/ai.allium new file mode 100644 index 0000000..9869947 --- /dev/null +++ b/specs/ai.allium @@ -0,0 +1,217 @@ +-- allium: 1 +-- bDS AI Integration +-- Scope: core (one-shot operations), extension Bucket C (chat + streaming) +-- Distilled from: src/main/engine/ChatEngine.ts, ai/providers.ts, +-- ai/chat.ts, ai/tasks.ts, SecureKeyStore.ts +-- The rewrite models AI access as two configurable OpenAI-compatible +-- endpoints (online + airplane mode) instead of a fixed named-provider set. + +use "./post.allium" as post +use "./media.allium" as media + +entity AiEndpoint { + kind: online | airplane + url: String + api_key: String? -- encrypted via SecureKeyStore; null for local models + model: String + -- online: cloud provider (OpenAI, Anthropic-via-proxy, etc.) + -- airplane: local model (Ollama, LM Studio, etc.) +} + +surface AiEndpointSurface { + context endpoint: AiEndpoint + + exposes: + endpoint.kind + endpoint.url + endpoint.api_key when endpoint.api_key != null + endpoint.model +} + +entity SecureKeyStore { + -- Encrypts API keys using the host operating system's secure storage. + -- Stored in application settings in encrypted form. + -- No plain-text fallback +} + +surface SecureKeyStoreSurface { + context _: SecureKeyStore +} + +entity ChatConversation { + title: String + model: String + created_at: Timestamp + updated_at: Timestamp + + messages: ChatMessage with conversation = this +} + +surface ChatConversationSurface { + context conversation: ChatConversation + + exposes: + conversation.title + conversation.model + conversation.created_at + conversation.updated_at + conversation.messages.count +} + +entity ChatMessage { + conversation: ChatConversation + role: system | user | assistant | tool + content: String + token_usage_input: Integer? + token_usage_output: Integer? + created_at: Timestamp +} + +surface ChatMessageSurface { + context message: ChatMessage + + exposes: + message.conversation + message.role + message.content + message.token_usage_input when message.token_usage_input != null + message.token_usage_output when message.token_usage_output != null + message.created_at +} + +surface OneShotAiSurface { + facing _: AiOperator + + provides: + AnalyzeTaxonomyRequested(post) + AnalyzeImageRequested(media) + AnalyzePostRequested(post) + DetectLanguageRequested(text) + TranslatePostRequested(post, target_language) + TranslateMediaRequested(media, target_language) +} + +surface AiChatSurface { + facing _: ChatOperator + + provides: + StartChatRequested(model) + SendChatMessageRequested(conversation, content) + RefreshModelCatalogRequested(endpoint) +} + +-- One-shot AI tasks (core scope, no streaming) +-- All use OpenAI Chat Completions wire format. +-- Endpoint routing: see AirplaneModeGating invariant below. +-- When no endpoint configured for current mode: disable AI, show toast. + +rule AnalyzeTaxonomy { + when: AnalyzeTaxonomyRequested(post) + requires: active_endpoint_configured + -- Suggests tags and categories for a post + ensures: TaxonomySuggestion(tags, categories) +} + +rule AnalyzeImage { + when: AnalyzeImageRequested(media) + requires: active_endpoint_configured + requires: is_image(media.mime_type) + -- Vision model generates alt text and caption + ensures: ImageAnalysisResult(alt, caption) +} + +rule AnalyzePost { + when: AnalyzePostRequested(post) + requires: active_endpoint_configured + -- Generates title, excerpt, slug suggestions + ensures: PostAnalysisResult(title, excerpt, slug) +} + +rule DetectLanguage { + when: DetectLanguageRequested(text) + requires: active_endpoint_configured + ensures: LanguageDetectionResult(language_code) +} + +rule TranslatePost { + when: TranslatePostRequested(post, target_language) + requires: active_endpoint_configured + -- Translates title, excerpt, content to target language + ensures: TranslationResult(title, excerpt, content) +} + +rule TranslateMedia { + when: TranslateMediaRequested(media, target_language) + requires: active_endpoint_configured + -- Translates title, alt, caption to target language + ensures: MediaTranslationResult(title, alt, caption) +} + +-- Chat (extension Bucket C scope, with streaming and tool use) + +rule StartChat { + when: StartChatRequested(model) + ensures: ChatConversation.created( + title: generated_chat_title(model), + model: model, + created_at: now, + updated_at: now + ) +} + +rule SendChatMessage { + when: SendChatMessageRequested(conversation, content) + requires: active_endpoint_configured + ensures: ChatMessage.created( + conversation: conversation, + role: user, + content: content, + token_usage_input: null, + token_usage_output: null, + created_at: now + ) + ensures: conversation.updated_at = now + ensures: AiStreamingResponse(conversation) + -- Streaming response with bounded tool-call loop. + -- Blog data tools for post/media querying during chat. + -- Token usage tracking (input, output, cache read/write). +} + +-- Model catalog + +rule RefreshModelCatalog { + when: RefreshModelCatalogRequested(endpoint) + -- Queries the endpoint's model list API + -- 5-minute cache TTL + ensures: ModelCatalogUpdated(endpoint) +} + +invariant AirplaneModeGating { + -- Endpoint routing based on airplane (offline) mode: + -- airplane_mode = true -> use airplane endpoint (local model) + -- airplane_mode = false -> use online endpoint (cloud provider) + -- active_endpoint_configured = true iff the endpoint for the + -- current mode has a non-empty url (and api_key for online). + -- When active endpoint is not configured: AI is unavailable, + -- show toast "AI unavailable — configure {online|airplane} endpoint in Settings" +} + +invariant TwoEndpointModel { + -- Two configurable OpenAI-compatible endpoints: + -- online: for cloud providers (requires API key) + -- airplane: for local models (no API key required) + -- Both use the OpenAI Chat Completions wire format. + -- Endpoint selection is configurable rather than tied to hard-coded providers. +} + +invariant AiSpecPartitioning { + -- This file covers two distinct but related AI contracts: + -- 1. Core one-shot operations (taxonomy, vision, translation, language detection) + -- 2. Extension chat/model-catalog behaviour + -- Both share the same endpoint routing and airplane-mode gating rules. +} + +invariant SecureKeyStorage { + -- API keys are never stored in plain text + -- Always encrypted via host secure storage before persistence +} diff --git a/specs/bds.allium b/specs/bds.allium new file mode 100644 index 0000000..cc12b21 --- /dev/null +++ b/specs/bds.allium @@ -0,0 +1,84 @@ +-- allium: 1 +-- bDS (Blogging Desktop Server) — Axiom Specification +-- Distilled from the existing bDS application at ../bDS/ +-- This is the behavioural baseline for the rewrite effort. + +-- An offline-first desktop application for blog authoring with +-- static site generation, SSH publishing, AI integration, and +-- external tool integration via MCP. + +-- Core domain +use "./project.allium" as project -- Multi-project management +use "./post.allium" as post -- Post lifecycle, frontmatter, file layout +use "./media.allium" as media -- Media import, thumbnails, sidecars +use "./translation.allium" as translation -- Post and media translations +use "./tag.allium" as tag -- Tags with mass operations +use "./template.allium" as template -- Liquid template management +use "./script.allium" as script -- Scripting (macros, utilities, transforms) +use "./menu.allium" as menu -- OPML navigation menu +use "./metadata.allium" as metadata -- Project config, categories, publishing prefs + +-- Infrastructure +use "./search.allium" as search -- FTS5 full-text search with Snowball stemming +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) +use "./task.allium" as task -- Background task manager +use "./i18n.allium" as i18n -- Split localization (UI vs content) + +-- UI +use "./layout.allium" as layout -- App shell, activity bar, status bar, panels +use "./tabs.allium" as tabs -- Tab system, editor routing, preview/pin +use "./sidebar_views.allium" as sidebar -- 10 sidebar views, content, behaviour +use "./modals.allium" as modals -- Shared modals (AI suggestions, confirm, gallery) +use "./editor_post.allium" as editor_post -- Post editor view and actions +use "./editor_media.allium" as editor_media -- Media editor view and actions +use "./editor_settings.allium" as editor_settings -- Settings + style views +use "./editor_tags.allium" as editor_tags -- Tags view and colour picker +use "./editor_chat.allium" as editor_chat -- Chat panel and model selector +use "./editor_script.allium" as editor_script -- Script editor +use "./editor_template.allium" as editor_template -- Template editor +use "./editor_misc.allium" as editor_misc -- Dashboard, menu, metadata diff, git diff, etc. + +-- Flows and side-effects +use "./ui_data_flow.allium" as ui_data_flow -- Sidebar/editor/tab reactive coordination +use "./engine_side_effects.allium" as engine_side_effects -- CRUD side-effect chains +use "./action_patterns.allium" as action_patterns -- AI gating, translation chains, confirmations + +-- Integration +use "./git.allium" as git -- Git operations, LFS, reconciliation +use "./mcp.allium" as mcp -- MCP server (tools, resources, proposals) +use "./ai.allium" as ai -- AI one-shot tasks and chat +use "./embedding.allium" as embedding -- Semantic similarity (HNSW vectors) +use "./cli_sync.allium" as cli_sync -- CLI-to-app notification sync +use "./metadata_diff.allium" as metadata_diff -- DB/filesystem diff and rebuild + +-- Compatibility contract +-- +-- MUST stay identical: +-- persistence semantics, post markdown frontmatter, +-- translation file naming, media sidecars, thumbnail conventions, +-- template file formats, menu OPML, generated routes/feeds/sitemaps, +-- full-text search behaviour, slug generation, metadata diff, +-- rebuild-from-filesystem +-- +-- MAY change intentionally: +-- implementation language, desktop container, UI framework, +-- editor implementation, internal process model, runtime libraries + +-- Resolved questions: +-- +-- 1. Slug generation scope: only German and English letters are used. +-- Verify transliteration preserves the established bDS behaviour for +-- ä/ö/ü/ß/ÄÖÜ. +-- +-- 2. Liquid subset: see template.allium for the exact subset. +-- Only 5 tags, 4 standard filters, 2 custom filters, 5 operators. +-- .size is property access on arrays, NOT a pipe filter. +-- +-- 3. Macro calling convention: [[macroslug param1=value1 ...]] +-- Double-bracket syntax, not Liquid tags. Identical to current app. +-- +-- 4. AI provider model: the rewrite uses two configurable OpenAI-compatible +-- endpoints (online + airplane mode) rather than a fixed named-provider set. +-- See ai.allium for details. diff --git a/specs/cli_sync.allium b/specs/cli_sync.allium new file mode 100644 index 0000000..f519bf5 --- /dev/null +++ b/specs/cli_sync.allium @@ -0,0 +1,68 @@ +-- allium: 1 +-- bDS CLI / App Notification Sync +-- Scope: extension (Bucket G — MCP + Automation) +-- Distilled from: src/main/engine/CliNotifier.ts, NotificationWatcher.ts + +entity DbNotification { + entity_type: String -- post, media, script, template + entity_id: String + action: created | updated | deleted + from_cli: Boolean + seen_at: Timestamp? + created_at: Timestamp + + -- Derived + is_processed: seen_at != null +} + +surface CliSyncRuntimeSurface { + facing _: CliSyncRuntime + + provides: + CliMutationPerformed(entity_type, entity_id, action) + DbFileChangeDetected() +} + +rule CliWriteNotification { + when: CliMutationPerformed(entity_type, entity_id, action) + -- CLI inserts notification row; app watches for it + ensures: DbNotification.created( + entity_type: entity_type, + entity_id: entity_id, + action: action, + from_cli: true, + seen_at: null + ) +} + +rule AppWatchNotifications { + when: DbFileChangeDetected() + -- Watches the persisted notification store for external mutations + -- Debounced at 100ms + let unseen = DbNotifications where seen_at = null and from_cli = true + for n in unseen: + ensures: n.seen_at = now + ensures: EngineCacheInvalidated(n.entity_type) + ensures: EntityChangedEvent(n.entity_type, n.entity_id, n.action) + -- IPC event to renderer for UI refresh +} + +rule PruneProcessedNotifications { + when: n: DbNotification.created_at + 1.hour <= now + requires: n.is_processed + -- Processed rows: prune after 1 hour + ensures: not exists n +} + +rule PruneUnprocessedNotifications { + when: n: DbNotification.created_at + 24.hours <= now + requires: not n.is_processed + -- Unprocessed rows: prune after 24 hours + ensures: not exists n +} + +invariant AppNoopNotifier { + -- The desktop application uses a no-op notifier for its own writes + -- It already knows about its own mutations + -- Only CLI writes produce notification rows +} diff --git a/specs/editor_chat.allium b/specs/editor_chat.allium new file mode 100644 index 0000000..8df4567 --- /dev/null +++ b/specs/editor_chat.allium @@ -0,0 +1,144 @@ +-- allium: 1 +-- bDS Chat Panel +-- Scope: UI content area — AI chat surface +-- Distilled from: ChatPanel.tsx + +-- Describes the layout and behaviour of the chat panel. + +use "./i18n.allium" as i18n + +-- ─── Chat panel ─────────────────────────────────────────────── + +value ChatPanelView { + conversation_id: String? + needs_api_key: Boolean + title: String -- conversation title or "New Chat" + selected_model_id: String? + messages: List + is_streaming: Boolean + input_text: String +} + +value ChatMessage { + role: String -- user | assistant | system + content: String -- user: plain text; assistant: GFM markdown + tool_markers: List + is_streaming: Boolean -- true while accumulating +} + +value ToolMarker { + tool_name: String + args_preview: String -- string args truncated to config.chat_tool_args_max_length + is_complete: Boolean -- checkmark when done, dot when in-progress +} + +value ModelSelectorDropdown { + groups: List + selected_model_id: String? +} + +surface ModelSelectorDropdownSurface { + context dropdown: ModelSelectorDropdown + + exposes: + dropdown.selected_model_id when dropdown.selected_model_id != null + for group in dropdown.groups: + group.provider_name + for model in group.models: + model.model_id + model.display_name + model.context_window + model.max_output_tokens +} + +value ModelProviderGroup { + provider_name: String -- e.g. "OpenAI", "Ollama", "LM Studio" + models: List +} + +value ModelEntry { + model_id: String + display_name: String + context_window: Integer + max_output_tokens: Integer +} + +config { + chat_tool_args_max_length: Integer = 30 + chat_input_max_height: Integer = 200 +} + +surface ChatPanelSurface { + context panel: ChatPanelView + + exposes: + panel.needs_api_key + panel.title + panel.selected_model_id + panel.is_streaming + panel.input_text + for msg in panel.messages: + msg.role + msg.content + msg.is_streaming + for tm in msg.tool_markers: + tm.tool_name + tm.args_preview + tm.is_complete + + provides: + ChatSendMessage(panel.conversation_id, panel.input_text) + when panel.input_text != "" and not panel.is_streaming + ChatAbortStreaming(panel.conversation_id) + when panel.is_streaming + ChatSelectModel(panel.conversation_id, model_id) + ChatOpenSettings() + when panel.needs_api_key + + @guarantee ApiKeyRequiredScreen + -- Shown when needs_api_key is true. + -- Key icon, title, description text, "Open Settings" button. + -- No chat functionality available until API key is set. + + @guarantee HeaderLayout + -- Left: conversation title (or "New Chat"), CSS ellipsis on overflow. + -- Right: model selector button opening dropdown. + + @guarantee ModelSelectorDropdown + -- Dropdown groups models by provider (section headers). + -- Each entry: model display name. + -- Expandable details: context window, max output tokens. + -- Selection is per-conversation override, persisted with conversation. + -- Changing model mid-conversation applies to subsequent messages only. + + @guarantee WelcomeScreen + -- Shown when no messages and not streaming. + -- Robot icon, title, description, 5 tip bullet points. + + @guarantee MessageRendering + -- User messages: plain text. + -- Assistant messages: rendered as GFM Markdown. + -- External images blocked (CSP), shown as links. + -- Tool markers: checkmark (done) or dot (in-progress) icon, + -- tool name, args truncated to config.chat_tool_args_max_length for strings. + -- Streaming: accumulating markdown + tool markers, thinking dots animation. + + @guarantee AutoScroll + -- Message area auto-scrolls to bottom on new messages. + + @guarantee InputArea + -- Abort/Stop button: square stop icon, visible only during streaming. + -- Auto-growing textarea: max config.chat_input_max_height px. + -- Enter sends message. Shift+Enter inserts newline. + -- Send button: up-arrow icon, disabled when input is empty or streaming. + + @guarantee AssistantActionDispatch + -- Assistant tool calls can trigger navigation actions: + -- open_post(id), open_media(id), open_settings(), etc. + -- Actions dispatched through store, same as user clicks. + -- Navigation actions open tabs with pin intent. + + @guarantee TokenTracking + -- Token usage tracked per conversation. + -- Displayed in status bar, not in the chat panel itself. +} diff --git a/specs/editor_media.allium b/specs/editor_media.allium new file mode 100644 index 0000000..ae2cdd1 --- /dev/null +++ b/specs/editor_media.allium @@ -0,0 +1,234 @@ +-- allium: 1 +-- bDS Media Editor View +-- Scope: UI content area — media editing surface +-- Distilled from: MediaEditor.tsx + +-- Describes the layout and behaviour of the media editor rendered in +-- the main content area when a media tab is active. + +use "./media.allium" as media +use "./i18n.allium" as i18n + +-- ─── Media editor ───────────────────────────────────────────── + +value MediaEditorView { + media_id: String + is_image: Boolean + file_name: String -- originalName, read-only + mime_type: String -- read-only + file_size: String -- formatted, read-only + dimensions: String? -- "W x H" if width/height exist, read-only + title: String? -- editable text input + alt_text: String? -- editable text input + caption: String? -- editable textarea (3 rows) + tags: String? -- comma-separated text input + author: String? -- editable text input + language: String? -- select from supported languages + translations: List + linked_posts: List +} + +value MediaTranslationItem { + language: String + flag_emoji: String + title: String? + alt_text: String? + caption: String? +} + +value LinkedPostItem { + post_id: String + title: String +} + +value PostPickerOverlay { + search_query: String + results: List + overflow_count: Integer? -- shown as "and N more" if > 0 +} + +surface PostPickerOverlaySurface { + context overlay: PostPickerOverlay + + exposes: + overlay.search_query + for result in overlay.results: + result.post_id + result.title + overlay.overflow_count when overlay.overflow_count != null +} + +value PostPickerResult { + post_id: String + title: String +} + +config { + media_post_picker_max_results: Integer = 10 +} + +surface MediaEditorSurface { + context editor: MediaEditorView + + exposes: + editor.file_name + editor.mime_type + editor.file_size + editor.dimensions when editor.dimensions != null + editor.is_image + editor.title + editor.alt_text + editor.caption + editor.tags + editor.author + editor.language + for t in editor.translations: + t.language + t.flag_emoji + t.title + for lp in editor.linked_posts: + lp.post_id + lp.title + + provides: + MediaAIImageAnalysisRequested(editor.media_id) + when editor.is_image + MediaDetectLanguageRequested(editor.media_id) + MediaTranslateMetadataRequested(editor.media_id, target_language) + MediaReplaceFileRequested(editor.media_id) + MediaSaveRequested(editor.media_id) + MediaDeleteRequested(editor.media_id) + MediaLinkToPostRequested(editor.media_id) + MediaTranslationEditClicked(editor.media_id, language) + MediaTranslationRefreshClicked(editor.media_id, language) + MediaTranslationDeleteClicked(editor.media_id, language) + MediaUnlinkPostRequested(editor.media_id, post_id) + + @guarantee HeaderLayout + -- Header bar with media display name. + -- Actions (right side): Quick Actions dropdown, Replace File button, + -- Save button, Delete button (danger style). + + @guarantee QuickActionsDropdown + -- Dropdown menu in header with three entries: + -- AI Image Analysis (robot icon) — only shown for image/* MIME types. + -- Detect Language (magnifier icon). + -- Translate Metadata (globe icon). + + @guarantee PreviewArea + -- Images: rendered via bds-media:// protocol with cache-busting timestamp. + -- Non-images: SVG file icon placeholder + original filename text. + + @guarantee MetadataForm + -- Form fields in order: + -- File Name (disabled input), MIME Type (disabled input), + -- Size + Dimensions row (disabled inputs), + -- Title (text input), Alt Text (text input), Caption (textarea, 3 rows), + -- Tags (comma-separated text input), Author (text input), + -- Language (select from supported languages). + + @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. + -- "No translations" message when list is empty. + + @guarantee LinkedPostsSection + -- "Link to Post" button opens inline post picker overlay. + -- List of currently linked posts with document icon. Click navigates to post tab. + -- Per-post unlink button (×). + -- "Not linked to any posts" message when list is empty. + + @guarantee PostPickerOverlay + -- Inline overlay positioned near "Link to Post" button (not a modal). + -- Search input filtering unlinked posts by title. + -- Up to config.media_post_picker_max_results results displayed. + -- "and N more" text when total exceeds limit. + -- Click result: links media to selected post, closes overlay. + + @guarantee NoAutoSave + -- Unlike the post editor, media editor requires explicit Save button. +} + +-- ─── Media editor actions ───────────────────────────────────── + +rule MediaAIImageAnalysis { + when: MediaAIImageAnalysisRequested(media_id) + -- Gate: airplane mode check (see action_patterns.allium AIOperationGating) + -- Only available for image/* MIME types (button hidden for non-images) + -- Uses image analysis model (vision-capable, not title model) + -- Input: AI-optimized JPEG thumbnail (448x448, generated on import) + -- Response: suggested title, alt text, caption + -- Opens AISuggestionsModal with 3 fields (title, alt, caption) + -- On confirm: applies checked fields, triggers explicit save +} + +rule MediaDetectLanguage { + when: MediaDetectLanguageRequested(media_id) + -- Gate: airplane mode check + -- Input: concatenation of title + alt + caption text + -- Response: detected language code + -- Immediately persists to media record (no modal, no confirmation) + -- Triggers sidecar rewrite +} + +rule MediaTranslateMetadata { + when: MediaTranslateMetadataRequested(media_id, target_language) + -- Gate: airplane mode check + -- Opens language picker modal (same pattern as post translate) + -- Two-step process: + -- 1. If source language not set: detect it first (auto-persist) + -- 2. Translate title, alt, caption to target language via title model + -- Creates/updates media translation record + -- Writes translated sidecar file: {path}.{lang}.meta +} + +rule MediaReplaceFile { + when: MediaReplaceFileRequested(media_id) + -- Opens native file dialog (no MIME type filter) + -- Copies selected file over existing media file path + -- If image: regenerates thumbnails synchronously (awaited) + -- Preview area updates with cache-busting timestamp query param +} + +rule MediaDeleteAction { + when: MediaDeleteRequested(media_id) + -- Opens ConfirmDeleteModal (custom modal, not native dialog) + -- Shows: media display name, linked posts count and list + -- Two buttons: Cancel, Delete (destructive red style) + -- On confirm: deletes file, sidecar, thumbnails, all translations, + -- post-media links, FTS index entry + -- Closes media tab, sidebar removes item + -- See engine_side_effects.allium DeleteMediaSideEffects +} + +rule MediaLinkToPost { + when: MediaLinkToPostRequested(media_id) + -- Opens inline post picker overlay (not a modal, positioned near button) + -- Search input filtering unlinked posts by title + -- Up to config.media_post_picker_max_results results shown + -- "and N more" text if results exceed limit + -- Click links media to selected post (updates sidecar linkedPostIds) + -- Linked posts list refreshes immediately +} + +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 +} + +rule MediaTranslationRefresh { + when: MediaTranslationRefreshClicked(media_id, language) + -- Gate: airplane mode check + -- Re-translates from source language to target via title model + -- Overwrites existing translation fields + -- Rewrites translated sidecar file +} + +rule MediaTranslationDelete { + when: MediaTranslationDeleteClicked(media_id, language) + -- Deletes translation record from DB + -- Deletes translated sidecar file: {path}.{lang}.meta + -- No confirmation dialog +} diff --git a/specs/editor_misc.allium b/specs/editor_misc.allium new file mode 100644 index 0000000..101ab3b --- /dev/null +++ b/specs/editor_misc.allium @@ -0,0 +1,786 @@ +-- allium: 1 +-- bDS Miscellaneous Editor Views +-- Scope: UI content area — dashboard, menu editor, metadata diff, git diff, +-- documentation, validation, find duplicates, import analysis +-- Distilled from: Editor.tsx (Dashboard), MenuEditorView.tsx, +-- MetadataDiffPanel.tsx, ImportAnalysisView.tsx + +-- Describes the layout and behaviour of smaller editor views that don't +-- warrant their own spec file. + +use "./tabs.allium" as tabs +use "./i18n.allium" as i18n +use "./generation.allium" as generation + +-- ─── Dashboard (no tab active) ─────────────────────────────── + +-- Shown as default/welcome view when no entity tab is active. + +value Dashboard { + title: String + subtitle: String + stats: DashboardStats + timeline: DashboardTimeline + tag_cloud: DashboardTagCloud + category_cloud: DashboardCategoryCloud + recent_posts: List +} + +value DashboardStats { + total_posts: Integer + published_count: Integer + draft_count: Integer + archived_count: Integer -- shown only if > 0 + media_count: Integer + image_count: Integer + total_media_size: String -- formatted B/KB/MB/GB + tag_count: Integer + category_count: Integer +} + +value DashboardTimeline { + months: List +} + +value DashboardTimelineMonth { + label: String -- month abbreviation + year: Integer + count: Integer +} + +value DashboardTagCloud { + tags: List + overflow_count: Integer? -- "and N more" when > config.dashboard_max_tags +} + +value DashboardTag { + name: String + count: Integer + color: String? +} + +value DashboardCategoryCloud { + categories: List +} + +value DashboardCategory { + name: String + count: Integer +} + +value DashboardRecentPost { + post_id: String + title: String -- "Untitled" as fallback + status: String -- draft | published + date: String -- locale-formatted +} + +config { + dashboard_max_tags: Integer = 40 + dashboard_tag_min_font: Integer = 11 + dashboard_tag_max_font: Integer = 22 + dashboard_recent_count: Integer = 5 + dashboard_timeline_months: Integer = 12 +} + +surface DashboardSurface { + context dash: Dashboard + + exposes: + dash.title + dash.subtitle + dash.stats.total_posts + dash.stats.published_count + dash.stats.draft_count + dash.stats.archived_count when dash.stats.archived_count > 0 + dash.stats.media_count + dash.stats.image_count + dash.stats.total_media_size + dash.stats.tag_count + dash.stats.category_count + for m in dash.timeline.months: + m.label + m.year + m.count + for t in dash.tag_cloud.tags: + t.name + t.count + t.color + dash.tag_cloud.overflow_count when dash.tag_cloud.overflow_count != null + for c in dash.category_cloud.categories: + c.name + c.count + for rp in dash.recent_posts: + rp.title + rp.status + rp.date + + provides: + DashboardRecentPostClicked(post_id, single) + DashboardRecentPostClicked(post_id, double) + + @guarantee StatCards + -- Three stat cards side by side. + -- Posts card: total number, breakdown tags (published/drafts/archived if > 0). + -- Media card: count, images count, total size (formatted bytes). + -- Tags card: count, categories count. + + @guarantee TimelineChart + -- Bar chart of posts over last config.dashboard_timeline_months months that have data. + -- Each bar: count label on top, month abbreviation + year below. + -- Bar height proportional to max count. + + @guarantee TagCloud + -- Up to config.dashboard_max_tags tags, sorted alphabetically. + -- Font size scaled config.dashboard_tag_min_font to config.dashboard_tag_max_font px + -- based on post count. + -- Tags with colours get coloured background with contrast text. + -- Hover title shows post count. + -- "and N more" text when overflow_count > 0. + + @guarantee CategoryCloud + -- All categories as badge-like tags. + -- Each shows category name + count. + + @guarantee RecentPosts + -- Last config.dashboard_recent_count posts by updatedAt descending. + -- Each row: title (or "Untitled"), status badge (draft/published), date. + -- Single-click: preview tab. Double-click: pin tab. +} + +-- ─── Menu editor view ──────────────────────────────────────── + +-- Visual editor for the OPML navigation menu (meta/menu.opml). +-- See menu.allium for data model. + +value MenuEditorView { + items: List +} + +value MenuTreeItem { + item_id: String + kind: String -- home | page | category_archive | submenu + label: String + children: List + is_home: Boolean -- true for the home item (protected) +} + +surface MenuEditorSurface { + context menu: MenuEditorView + + exposes: + for item in menu.items: + item.kind + item.label + item.children + item.is_home + + provides: + MenuItemAdded(kind, data) + MenuSaveRequested() + MenuItemDeleted(item_id) + when not item.is_home + MenuItemMoved(item_id, direction) + when not item.is_home + + @guarantee HeaderLayout + -- Title + description text. + + @guarantee Toolbar + -- 8 icon buttons with tooltips: + -- Add Entry (+), Save (floppy), Add Category Archive, + -- Move Up, Move Down, Indent, Unindent, Delete. + + @guarantee TreeView + -- Drag-and-drop tree with items showing: + -- Drag handle, kind icon (home/page/category-archive/submenu SVGs), + -- title, selected row highlighting. + -- Nested items indented to show hierarchy. + + @guarantee InlineEditing + -- When creating page item: inline PageInput with search + -- (filters posts in "page" category). + -- When creating category archive: inline CategoryInput with search/create. + -- Escape cancels, selection confirms. + + @guarantee HomeItemProtection + -- Home item cannot be moved, reordered, or deleted. + -- Maximum one home item allowed. + + @guarantee DragDrop + -- Drag handle on each item. + -- Auto-expand collapsed submenus on hover (config.menu_drag_expand_delay ms delay). + -- Drop indicators show target position and nesting level. + + @guarantee MoveDirections + -- Up/Down: reorder within same nesting level. + -- Indent: nest under previous sibling (becomes child). + -- Unindent: move to parent's level (becomes next sibling of parent). +} + +config { + menu_drag_expand_delay: Integer = 450 +} + +rule MenuAddItem { + when: MenuItemAdded(kind, data) + -- kind = page: opens lazy-loaded page picker (posts with "page" category) + -- kind = category_archive: opens lazy-loaded category picker + -- kind = submenu: creates empty container node for nesting children + -- kind = home: always available, maximum one allowed + ensures: MenuTreeUpdated() +} + +rule MenuSave { + when: MenuSaveRequested() + -- Serializes tree to OPML 2.0, writes meta/menu.opml + ensures: MenuFileWritten() +} + +rule MenuMoveItem { + when: MenuItemMoved(item_id, direction) + -- direction = up | down | indent | unindent + requires: not is_home_item(item_id) + ensures: MenuTreeUpdated() +} + +rule MenuDeleteItem { + when: MenuItemDeleted(item_id) + requires: not is_home_item(item_id) + -- Removes item and all children, no confirmation dialog + ensures: MenuTreeUpdated() +} + +-- ─── Metadata diff view ────────────────────────────────────── + +-- Shows DB vs filesystem differences for all entity types. +-- See metadata_diff.allium for diff field definitions. + +value MetadataDiffView { + is_scanning: Boolean + active_entity_tab: String -- posts | media | scripts | templates + diff_stats: MetadataDiffStats + field_summaries: List + items: List + orphan_files: List +} + +value MetadataDiffStats { + total_posts: Integer + published_posts: Integer + draft_posts: Integer + media_files: Integer + scripts: Integer + templates: Integer +} + +value MetadataDiffFieldSummary { + field_name: String + diff_count: Integer +} + +value MetadataDiffItem { + entity_name: String + entity_type: String + file_missing: Boolean -- badge shown when file not found + field_diffs: List +} + +value MetadataDiffField { + field_name: String + db_value: String? + file_value: String? +} + +value MetadataDiffOrphanFile { + file_path: String + entity_type: String +} + +surface MetadataDiffSurface { + context diff: MetadataDiffView + + exposes: + diff.is_scanning + diff.active_entity_tab + diff.diff_stats + for fs in diff.field_summaries: + fs.field_name + fs.diff_count + for item in diff.items: + item.entity_name + item.file_missing + for fd in item.field_diffs: + fd.field_name + fd.db_value + fd.file_value + for orphan in diff.orphan_files: + orphan.file_path + + provides: + MetadataDiffScanRequested() + MetadataDiffSyncFieldToFile(entity_name, field_name) + MetadataDiffSyncFieldToDb(entity_name, field_name) + MetadataDiffSyncAllFieldToFile(field_name) + MetadataDiffSyncAllFieldToDb(field_name) + MetadataDiffImportOrphan(file_path) + + @guarantee HeaderLayout + -- Title + description text. + -- Stats row: 6 stat items (total posts, published, drafts, media, scripts, templates). + + @guarantee ScanAction + -- Scan/Rescan button at top. + -- Progress bar + message during scan. + + @guarantee EntityTabs + -- Tabs: Posts, Media, Scripts, Templates — each with badge count of diffs. + + @guarantee FieldSummaryPills + -- Clickable filter pills per field, each with count. + -- Two bulk sync buttons per pill: DB→File and File→DB (syncs all items for that field). + + @guarantee DiffItemCards + -- Per-item card: header (entity label + file-missing badge), + -- field rows (field name, DB value, file value), + -- two sync buttons per field (DB→File, File→DB). + -- Button disappears after successful sync (field now matches). + + @guarantee OrphanFilesSection + -- Files on disk with no matching DB record shown at bottom of each entity tab. + -- "Import" button creates DB record from file metadata. + + @guarantee NoConfirmation + -- Individual field syncs require no confirmation dialog. +} + +-- ─── Git diff view ──────────────────────────────────────────── + +-- Renders diff for a file (working tree vs HEAD) or a commit. +-- File diff: id = "git-diff:{filePath}" +-- Commit diff: id = "git-diff:commit:{commitHash}" + +value GitDiffView { + diff_id: String + diff_type: String -- file | commit + display_mode: String -- inline | side_by_side (from editor settings) +} + +surface GitDiffSurface { + context diff: GitDiffView + + exposes: + diff.diff_id + diff.diff_type + diff.display_mode + + @guarantee DiffDisplayModes + -- Supports inline and side-by-side diff display modes. + -- Mode comes from editor settings (Diff View Style). + + @guarantee ReadOnly + -- No actions beyond viewing — changes are managed via git sidebar. +} + +-- ─── Documentation views ───────────────────────────────────── + +-- documentation: renders DOCUMENTATION.md as styled HTML +-- api_documentation: renders API.md as styled HTML + +surface DocumentationSurface { + @guarantee MarkdownRendering + -- Renders markdown file as styled HTML. + -- No edit actions. Read-only view. +} + +-- ─── Site validation view ─────────────────────────────────── + +value SiteValidationReport { + project_id: String + expected_url_count: Integer + existing_html_count: Integer + missing_url_paths: List -- in sitemap, no HTML on disk + extra_url_paths: List -- HTML on disk, not in sitemap + updated_post_url_paths: List -- source .md newer than HTML + affected_sections: Set +} + +surface SiteValidationSurface { + context report: SiteValidationReport + + exposes: + report.project_id + report.expected_url_count + report.existing_html_count + report.missing_url_paths + report.extra_url_paths + report.updated_post_url_paths + report.affected_sections + + provides: + SiteValidationScanRequested() + SiteValidationApplyRequested(report) + when report.missing_url_paths.count > 0 + or report.extra_url_paths.count > 0 + or report.updated_post_url_paths.count > 0 + + @guarantee SummaryLine + -- "Expected URLs: N — Existing HTML URLs: N — Missing: N — Extra: N — Updated: N" + + @guarantee UrlSections + -- Three sections: Missing URLs, Extra URLs, Updated URLs. + -- Each section: heading + list of URL paths, or "None found". + + @guarantee ApplyAction + -- Apply button disabled when nothing to fix. + -- On apply: renders missing, deletes extra, re-renders updated. + -- Toast: "Validation applied: N rendered, N deleted". +} + +rule SiteValidationScan { + when: SiteValidationScanRequested() + -- Parses entries from sitemap.xml into expected URL set + -- Scans HTML output dir for index.html files (zero-byte = missing) + -- Compares source .md mtime against generated HTML mtime + ensures: SiteValidationReport +} + +rule SiteValidationApply { + when: SiteValidationApplyRequested(report) + -- Classifies affected paths into generation sections (core, single, category, tag, date) + -- Renders only affected sections in parallel + -- Deletes extra HTML files, removes empty directories + -- Regenerates calendar if anything changed + -- Rebuilds search index if anything rendered or deleted + ensures: ApplyValidationRequested(report.project_id, report.affected_sections) +} + +-- ─── Translation validation view ─────────────────────────── + +value TranslationValidationReport { + checked_database_row_count: Integer + checked_filesystem_file_count: Integer + invalid_database_rows: List + invalid_filesystem_files: List +} + +value TranslationValidationIssue { + issue: String + -- Issue kinds: + -- missing_source_post: translationFor points to nonexistent post + -- same_language_as_canonical: translation language matches source post language + -- do_not_translate_has_translations: source post is doNotTranslate but has translations + -- content_in_database: published translation still has content in DB (should be on disk) + translation_id: String? + translation_for: String + canonical_language: String? + translation_language: String + title: String? + file_path: String? +} + +surface TranslationValidationSurface { + context report: TranslationValidationReport + + exposes: + report.checked_database_row_count + report.checked_filesystem_file_count + for issue in report.invalid_database_rows: + issue.issue + issue.translation_id + issue.translation_for + issue.canonical_language + issue.translation_language + issue.title + issue.file_path + for issue in report.invalid_filesystem_files: + issue.issue + issue.file_path + + provides: + TranslationValidationScanRequested() + TranslationValidationFixRequested(report) + when report.invalid_database_rows.count > 0 + or report.invalid_filesystem_files.count > 0 + + @guarantee SummaryLine + -- "Checked DB rows: N — Checked files: N — Invalid DB rows: N — Invalid files: N" + + @guarantee IssueSections + -- Two sections: Database Issues, Filesystem Issues. + -- Each issue rendered as card with coloured left border. + -- Card shows: issue label, source post ID, translation ID, title, languages, file path. + + @guarantee IssueTypes + -- same_language_as_canonical: translation language matches source. + -- do_not_translate_has_translations: source is doNotTranslate. + -- content_in_database: published translation has content in DB. + -- missing_source_post: translationFor references nonexistent post. + + @guarantee FixAction + -- Revalidate button + Fix button (disabled when no issues). + -- Fix: content_in_database -> flush to .md file, set content null. + -- Other issues -> delete DB row or .md file. + -- After fix: automatically re-validates. + -- Toast: "Deleted N DB rows and N files, flushed N translations to disk". +} + +rule TranslationValidationScan { + when: TranslationValidationScanRequested() + -- Database pass: checks all translation rows for integrity issues + -- Filesystem pass: scans posts/ for translation .md files, checks frontmatter + ensures: TranslationValidationReport +} + +rule TranslationValidationFix { + when: TranslationValidationFixRequested(report) + -- content_in_database: flushes content to .md file, sets content = null in DB + -- missing_source_post | same_language_as_canonical | do_not_translate: + -- DB issues: deletes translation row + -- Filesystem issues: deletes the .md file + -- After fix: automatically re-validates + ensures: TranslationValidationScan +} + +-- ─── Find duplicates view ────────────────────────────────── + +value DuplicateSearchResult { + pairs: List + has_more: Boolean -- pagination with config.duplicate_page_size per page +} + +value DuplicatePair { + post_id_a: String + title_a: String + post_id_b: String + title_b: String + similarity: Decimal -- 0.0 to 1.0 + exact_match: Boolean -- true if titles + content identical +} + +config { + duplicate_similarity_threshold: Decimal = 0.92 + duplicate_page_size: Integer = 500 + duplicate_neighbor_count: Integer = 21 +} + +surface DuplicatesSurface { + context result: DuplicateSearchResult + + exposes: + for pair in result.pairs: + pair.title_a + pair.title_b + pair.similarity + pair.exact_match + result.has_more + + provides: + DuplicateSearchRequested() + DuplicatePairDismissed(post_id_a, post_id_b) + DuplicatePairsBatchDismissed(pair_ids) + DuplicatePostClicked(post_id) + DuplicateShowMoreRequested() + when result.has_more + + @guarantee SemanticSimilarityGate + -- Requires semanticSimilarityEnabled in project metadata. + -- If disabled: shows "Semantic similarity is not enabled" message. + -- No search functionality available when disabled. + + @guarantee ActionsBar + -- Refresh button, Check All, Uncheck All, + -- Dismiss Checked (with count), disabled when none checked. + + @guarantee PairRows + -- Each row: checkbox, post A title (clickable -> opens tab), + -- arrow, post B title (clickable -> opens tab), + -- similarity badge (percentage or "Exact Match"), + -- Dismiss button. + -- Exact matches styled distinctly from similarity matches. + + @guarantee Pagination + -- "Show More" button when has_more is true. + -- config.duplicate_page_size pairs per page. + + @guarantee BatchDismiss + -- Batch insert in chunks of 100. + -- Dismissed pairs excluded from future searches. +} + +rule DuplicateSearch { + when: DuplicateSearchRequested() + requires: semantic_similarity_enabled + -- Loads USearch vector index for project + -- For each indexed post: search config.duplicate_neighbor_count nearest neighbors + -- similarity = max(0, 1 - distance) + -- Filter: similarity >= config.duplicate_similarity_threshold, exclude dismissed + -- For 100% embedding similarity: load post bodies, compare title+content + -- If identical: exact_match = true + -- Sort: exact matches first, then descending similarity + ensures: DuplicateSearchResult +} + +rule DuplicateDismiss { + when: DuplicatePairDismissed(post_id_a, post_id_b) + -- Inserts into dismissed_duplicate_pairs with canonical ID ordering + -- Excluded from future searches + ensures: PairDismissed(post_id_a, post_id_b) +} + +rule DuplicateBatchDismiss { + when: DuplicatePairsBatchDismissed(pair_ids) + -- Batch insert in chunks of 100 + ensures: for pair in pair_ids: PairDismissed(pair) +} + +-- ─── Import analysis view ─────────────────────────────────── + +-- Editor for WXR (WordPress eXtended RSS) import definitions. +-- Keyed by import definition ID. Opened as always-pinned tab. + +value ImportAnalysisView { + definition_id: String + definition_name: String -- editable name input + uploads_folder_path: String? -- path display + Browse button + wxr_file_path: String? -- path display + Select & Analyze button + is_loading: Boolean + report: ImportAnalysisReport? +} + +value ImportAnalysisReport { + site_info: ImportSiteInfo + post_stats: ImportEntityStats + page_stats: ImportEntityStats + media_stats: ImportMediaStats + category_stats: ImportTaxonomyStats + tag_stats: ImportTaxonomyStats + date_distribution: List + conflicts: List + macros: List +} + +value ImportSiteInfo { + title: String + url: String + language: String + source_file: String +} + +value ImportEntityStats { + new_count: Integer + update_count: Integer + conflict_count: Integer + duplicate_count: Integer +} + +value ImportMediaStats { + new_count: Integer + update_count: Integer + conflict_count: Integer + duplicate_count: Integer + missing_count: Integer +} + +value ImportTaxonomyStats { + existing_count: Integer + mapped_count: Integer + new_count: Integer +} + +value ImportYearDistribution { + year: Integer + post_count: Integer + media_count: Integer +} + +value ImportConflict { + item_type: String -- post | page | media + item_name: String + resolution: String -- import | skip | merge +} + +value ImportMacro { + name: String + usage_count: Integer + parameters: List + validation_status: String -- valid | invalid | unknown +} + +surface ImportAnalysisSurface { + context analysis: ImportAnalysisView + + exposes: + analysis.definition_name + analysis.uploads_folder_path + analysis.wxr_file_path + analysis.is_loading + analysis.report when analysis.report != null + + provides: + ImportAnalyzeRequested(analysis.definition_id, file_path) + when analysis.wxr_file_path != null + ImportExecuteRequested(analysis.definition_id) + when analysis.report != null + ImportConflictResolutionChanged(item_name, resolution) + ImportTaxonomyMappingChanged(source_term, target_term) + ImportAITaxonomyAnalysisRequested(analysis.definition_id) + + @guarantee FileSelectors + -- Uploads folder: path display + Browse button (native folder dialog). + -- WXR file: path display + Select & Analyze button (native file dialog). + + @guarantee LoadingState + -- Spinner + progress step + detail text during analysis. + + @guarantee SiteInfoCard + -- Shows: site title, URL, language, source file path. + + @guarantee StatCards + -- Posts (new/update/conflict/duplicate), Pages (same), + -- Media (new/update/conflict/duplicate/missing), + -- Categories (existing/mapped/new), Tags (existing/mapped/new). + + @guarantee DateDistribution + -- Year-by-year bar charts for posts + media. + + @guarantee ConflictsSection + -- Collapsible. Per-item dropdown: Import/Skip/Merge. + -- Default: Import for new items, Skip for existing matches. + + @guarantee TaxonomySection + -- Collapsible. Category + tag pills. + -- Click pill to map to existing term. Inline edit with suggestion dropdown. + -- AI analyze button (with model selector dropdown). + -- Gate: airplane mode check for AI taxonomy analysis. + + @guarantee MacrosSection + -- Collapsible. Discovered macros with usage counts, parameters, + -- validation status (valid/invalid/unknown badges). + + @guarantee ExecuteAction + -- Execute button shows importable counts (tags, posts, media, pages). + -- Disabled if nothing to import. + -- Progress bar during execution (current/total, phase, detail, ETA). + -- No confirmation dialog — executes immediately. + + @guarantee CreatedEntitiesRefresh + -- Created entities appear in sidebar immediately after execution. +} + +rule ImportSelectAndAnalyze { + when: ImportAnalyzeRequested(definition_id, file_path) + -- Parses WXR XML file + -- Extracts: posts, pages, media, tags, categories, authors + -- Shows summary counts per entity type + -- Identifies conflicts: duplicate slugs, existing categories/tags +} + +rule ImportExecute { + when: ImportExecuteRequested(definition_id) + -- No confirmation dialog — executes immediately + -- Processes items per conflict resolution settings + -- Creates posts, media, tags, categories as needed + -- Summary with counts shown in import view on completion + -- Created entities appear in sidebar immediately (store updated) +} diff --git a/specs/editor_post.allium b/specs/editor_post.allium new file mode 100644 index 0000000..d1d2ae2 --- /dev/null +++ b/specs/editor_post.allium @@ -0,0 +1,317 @@ +-- allium: 1 +-- bDS Post Editor View +-- Scope: UI content area — post editing surface +-- Distilled from: PostEditor.tsx + +-- Describes the layout and behaviour of the post editor rendered in +-- the main content area when a post tab is active. +-- Tab routing is in tabs.allium. Sidebar navigation is in sidebar_views.allium. + +use "./tabs.allium" as tabs +use "./post.allium" as post +use "./i18n.allium" as i18n + +-- ─── Post editor ────────────────────────────────────────────── + +value PostEditorView { + post_id: String + header: PostEditorHeader + metadata: PostEditorMetadata + metadata_expanded: Boolean -- starts expanded when title is empty + excerpt_expanded: Boolean + editor_mode: String -- visual | markdown | preview + footer: PostEditorFooter +} + +value PostEditorHeader { + title: String -- post title or "Untitled" + is_dirty: Boolean + status: String -- draft | published | archived + is_auto_saving: Boolean +} + +value PostEditorMetadata { + title: String -- editable text input + tags: List -- autocomplete chip input + author: String? -- text input + language: String? -- select from supported languages + do_not_translate: Boolean -- checkbox + slug: String -- read-only text input + categories: List -- chip input + template_slug: String? -- select (shown only when templates exist) + post_links: PostLinksPanel + linked_media: List +} + +value PostLinksPanel { + backlinks: List -- posts linking to this post + outlinks: List -- posts this post links to +} + +value PostLinkReference { + post_id: String + title: String +} + +value LinkedMediaItem { + media_id: String + has_thumbnail: Boolean + name: String + sort_order: Integer +} + +value PostEditorFooter { + created_at: String -- locale-formatted date + updated_at: String -- locale-formatted date + published_at: String? -- locale-formatted date, only when post was published +} + +value TranslationFlag { + language: String + flag_emoji: String + status: String -- draft | published + is_active: Boolean -- true when this language is currently being edited +} + +surface TranslationFlagSurface { + context flag: TranslationFlag + + exposes: + flag.language + flag.flag_emoji + flag.status + flag.is_active +} + +surface PostEditorSurface { + context editor: PostEditorView + + exposes: + editor.header.title + editor.header.is_dirty + editor.header.status + editor.header.is_auto_saving + editor.metadata_expanded + editor.excerpt_expanded + editor.editor_mode + editor.footer.created_at + editor.footer.updated_at + editor.footer.published_at when editor.footer.published_at != null + + provides: + PostAIAnalysisRequested(editor.post_id) + PostTranslateRequested(editor.post_id, target_language) + PostSaved(editor.post_id) + PostPublishRequested(editor.post_id) + when editor.header.status = draft + PostDiscardRequested(editor.post_id) + when editor.header.status = draft + PostDeleteRequested(editor.post_id) + PostInsertLinkRequested(editor.post_id) + when editor.editor_mode = markdown + PostInsertMediaRequested(editor.post_id) + when editor.editor_mode = markdown + PostGalleryRequested(editor.post_id) + ImageDroppedOnEditor(editor.post_id, file_path) + PostLanguageDetectRequested(editor.post_id) + + @guarantee HeaderLayout + -- Header bar with two areas. + -- Left: title text with dirty indicator dot (●) when is_dirty is true. + -- Right: status badge, auto-save indicator (when saving), + -- Quick Actions dropdown, Publish button (only when draft, success style), + -- Discard button (only when draft), Delete button. + + @guarantee QuickActionsDropdown + -- Dropdown menu in header with two entries: + -- AI Analysis (robot icon) — suggests title, excerpt, slug. + -- Translate Post (globe icon) — opens translation modal. + + @guarantee MetadataSection + -- Collapsible section. Starts expanded when title is empty. + -- Two-column layout. + -- Left column: Title, Tags, Author, Language + detect button, + -- Do Not Translate checkbox, Slug (read-only), Categories, + -- Template (select, only when templates exist), PostLinks. + -- Right column: LinkedMediaPanel. + + @guarantee TagAutocomplete + -- Tag input with autocomplete. + -- Standard: prefix match on existing tag names (case-insensitive). + -- When semanticSimilarityEnabled: also suggests tags from 10 similar posts, + -- weighted by similarity, top 5 shown. + -- Merged results: prefix matches first, then embedding suggestions. + + @guarantee TranslationFlagsBar + -- Row of flag emoji buttons inline with metadata toggle. + -- One flag per language: canonical language + each translation. + -- Each flag shows status (draft/published) via CSS class. + -- Active flag highlighted. Click switches editor to that language's draft. + + @guarantee ExcerptSection + -- Collapsible section with textarea (4 rows). + + @guarantee EditorBodyToolbar + -- Toolbar: "Content" label, mode toggle (Visual/Markdown/Preview), + -- action buttons (markdown mode only): Gallery (with media count), + -- Insert Post Link, Insert Media. + + @guarantee EditorModes + -- Visual: rich-text WYSIWYG editor. + -- Markdown: code editor with markdown-with-macros language, + -- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font. + -- Preview: iframe showing rendered preview. + + @guarantee DragDropImages + -- Drop image file onto editor area triggers import chain. + + @guarantee FooterLayout + -- Three date stamps: Created, Updated, Published (only when published_at exists). + -- Locale-formatted dates. + + @guarantee LinkedMediaPanel + -- Right column of metadata showing media items linked to this post. + -- Actions: Import & Link (native file dialog), Link Existing (media picker), + -- Unlink (no confirmation), Reorder (drag handle), Click (opens media tab). +} + +config { + post_auto_save_delay: Integer = 3000 + post_content_sample_length: Integer = 2000 +} + +invariant PostAutoSave { + -- Auto-saves after config.post_auto_save_delay ms of idle in the editor. + -- Also auto-saves on unmount/tab switch. + -- Ctrl/Cmd+S triggers immediate save. + -- Saves: title, content, excerpt, tags, categories, templateSlug, language. +} + +invariant PostDirtyTracking { + -- Compares canonical draft + translation drafts against saved state. + -- Dirty indicator shown in header (●) and tab bar. +} + +invariant PostEditorModePersistence { + -- Editor mode (visual/markdown/preview) persists per session. + -- Default mode comes from editor settings. +} + +-- ─── Post editor actions ──────────────────────────────────── + +rule PostAIAnalysis { + when: PostAIAnalysisRequested(post_id) + -- Gate: airplane mode check (see action_patterns.allium AIOperationGating) + -- Uses title model (not default chat model) + -- Input: post title + excerpt + content (first config.post_content_sample_length chars) + -- Response: suggested title, excerpt, slug + -- Opens AISuggestionsModal with 3 fields: + -- Each field: current value, suggested value, accept checkbox + -- Slug field locked (no accept checkbox) if post was ever published + -- On confirm: applies only checked fields, triggers auto-save +} + +rule PostTranslateAction { + when: PostTranslateRequested(post_id, target_language) + -- Gate: airplane mode check + -- Opens language picker modal: + -- Available target languages from project blogLanguages + -- Existing translations shown with status badge (draft/published) + -- Two sequential AI calls via title model: + -- 1. Translate metadata (title, excerpt) to target language + -- 2. Translate content (full markdown body) to target language + -- Creates/updates translation record in DB + -- If source post is published: transitions source to draft + -- (copies file content back to DB so it can be edited) +} + +rule PostAutoTranslateOnSave { + when: PostSaved(post_id) + -- Gate: airplane mode check + auto_translate not disabled (doNotTranslate=false) + -- For each configured blog language missing a translation: + -- Enqueue background translation task (title model) + -- Each task: translate metadata + content, create translation record + -- Cascades to linked media: for each linked media item, + -- translate media metadata for missing languages + -- See action_patterns.allium AutoTranslationChain for full chain +} + +rule PostPublishAction { + when: PostPublishRequested(post_id) + -- Implicit save first (awaited) if post is dirty + -- Then calls engine publish (see engine_side_effects.allium PublishPostSideEffects) + -- Also publishes all translations whose source language is published + -- UI updates: status badge -> published, sidebar section move +} + +rule PostDiscardChanges { + when: PostDiscardRequested(post_id) + -- Only available for published posts with pending draft changes + -- System confirm dialog: "Discard changes to this post?" + -- On confirm: reads published version from .md file, + -- restores DB to published state (content=null, status=published) + -- Editor reloads with restored content +} + +rule PostDeleteAction { + when: PostDeleteRequested(post_id) + -- System confirm dialog: "Delete this post?" + -- If published: also deletes .md file and all translation files + -- If never published: only deletes DB record + -- Removes from DB, closes tab, sidebar removes item + -- See engine_side_effects.allium DeletePostSideEffects +} + +rule PostInsertLink { + when: PostInsertLinkRequested(post_id) + -- Keyboard shortcut: Ctrl/Cmd+K + -- Opens InsertPostLinkModal with two tabs: Internal, External + -- Internal tab: + -- Search input (debounced, queries post titles) + -- Results list: title + status badge (draft/published) + -- If semantic similarity enabled: results ranked by similarity + -- Click inserts markdown link: [title](/YYYY/MM/DD/slug) + -- "Create Post" option at bottom of search results: + -- Creates new post with search query as title + -- Inserts link to newly created post + -- External tab: + -- URL input + optional display text input + -- Inserts: [text](url) or bare url if no display text +} + +rule PostInsertMedia { + when: PostInsertMediaRequested(post_id) + -- Opens InsertMediaModal (media search variant) + -- Search input, grid of media items with bds-thumb:// thumbnails + -- Click inserts markdown: + -- Images: ![alt](bds-media://id) + -- Non-images: [originalName](bds-media://id) +} + +rule PostGalleryAction { + when: PostGalleryRequested(post_id) + -- Opens gallery overlay showing all media linked to this post + -- Image grid with bds-thumb:// thumbnails + -- Click on image opens lightbox (full-size bds-media:// preview) + -- Lightbox: left/right arrow navigation, close button, ESC to close +} + +rule PostDragDropImage { + when: ImageDroppedOnEditor(post_id, file_path) + -- Chain of operations (see action_patterns.allium DragDropImageChain): + -- 1. Import media file -> media record + file copy + sidecar + -- 2. Generate thumbnails (async: small/medium/large/ai) + -- 3. Link media to post (update sidecar linkedPostIds) + -- 4. Insert markdown image at cursor: ![](bds-media://id) + -- 5. If AI available: AI image analysis (async, auto-applied, no modal) + -- 6. If auto-translate enabled: cascade translate media metadata + -- Steps 1-4 synchronous. Steps 5-6 background tasks. +} + +rule PostLanguageDetect { + when: PostLanguageDetectRequested(post_id) + -- Gate: airplane mode check + -- Sends content sample to title model + -- Auto-sets post language field (no modal) + -- Triggers auto-save +} diff --git a/specs/editor_script.allium b/specs/editor_script.allium new file mode 100644 index 0000000..7cc5a81 --- /dev/null +++ b/specs/editor_script.allium @@ -0,0 +1,96 @@ +-- allium: 1 +-- bDS Script Editor View +-- Scope: UI content area — script editing surface +-- Distilled from: ScriptEditor.tsx + +-- Describes the layout and behaviour of the script editor. +-- See script.allium for entity model. + +use "./script.allium" as script +use "./i18n.allium" as i18n + +-- ─── Script editor view ────────────────────────────────────── + +value ScriptEditorView { + script_id: String + title: String -- editable text input + slug: String -- editable text input, auto-generated from title + kind: String -- select: utility | macro | transform + entrypoint: String -- select: dynamically discovered functions, "main" always first + enabled: Boolean -- checkbox + content: String -- code editor content + created_at: String -- locale-formatted date + updated_at: String -- locale-formatted date +} + +surface ScriptEditorSurface { + context editor: ScriptEditorView + + exposes: + editor.title + editor.slug + editor.kind + editor.entrypoint + editor.enabled + editor.created_at + editor.updated_at + + provides: + ScriptSaveRequested(editor.script_id) + 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, + -- Check Syntax button, Delete button (danger style). + + @guarantee MetadataRow + -- Two rows of metadata fields above the editor. + -- Row 1: Title (text input), Slug (text input, auto-generated from title). + -- Row 2: Kind (select: utility/macro/transform), + -- Entrypoint (select: dynamically discovered functions with "main" always first), + -- Enabled (checkbox). + + @guarantee EditorBody + -- Code editor with syntax highlighting for the configured scripting language. + -- Toolbar: "Content" label. + -- Syntax errors shown as inline markers in editor gutter. + + @guarantee FooterLayout + -- Created date and Updated date, locale-formatted. +} + +-- ─── Script editor actions ────────────────────────────────── + +rule ScriptSave { + when: ScriptSaveRequested(script_id) + -- Syntax check first using the configured scripting runtime semantics + -- If syntax error: blocks save, shows error inline in editor gutter + -- If valid: bumps version, saves to DB + rewrites the published script file + -- Entrypoint list re-discovered from AST after save +} + +rule ScriptCheckSyntax { + when: ScriptCheckSyntaxRequested(script_id) + -- Validates script syntax without saving + -- Shows errors inline in editor gutter + -- Success shown as toast or status indicator +} + +rule ScriptRun { + when: ScriptRunRequested(script_id) + -- Executes script in the configured runtime + -- Calls configured entrypoint function (default: main) + -- stdout/stderr directed to Output panel tab + -- Output panel auto-opens if not already visible + -- Errors shown in Output panel with line numbers +} + +rule ScriptDelete { + when: ScriptDeleteRequested(script_id) + -- No confirmation dialog + -- Deletes DB record + published script file on disk + -- Closes script tab, sidebar removes item +} diff --git a/specs/editor_settings.allium b/specs/editor_settings.allium new file mode 100644 index 0000000..b6f1b13 --- /dev/null +++ b/specs/editor_settings.allium @@ -0,0 +1,271 @@ +-- allium: 1 +-- bDS Settings and Style Views +-- Scope: UI content area — settings + style editing surfaces +-- Distilled from: SettingsView.tsx, StyleView.tsx + +-- Describes the layout and behaviour of the settings and style views. + +use "./i18n.allium" as i18n + +-- ─── Settings view ──────────────────────────────────────────── + +value SettingsView { + search_query: String? -- filters sections by keyword match + active_sections: List -- visible sections after search filter + project_section: SettingsProjectSection? + editor_section: SettingsEditorSection? + categories: List + ai_section: SettingsAISection? + publishing_section: SettingsPublishingSection? + mcp_section: SettingsMCPSection? +} + +value SettingsProjectSection { + project_name: String -- text input + project_description: String -- textarea (3 rows) + data_path: String -- text input + Browse button + Reset button + public_url: String -- url input + main_language: String -- select + blog_languages: List -- checkboxes (main language disabled) + default_author: String -- text input + max_posts_per_page: Integer -- number input (1-500, default 50) + blogmark_category: String -- select from categories +} + +value SettingsEditorSection { + default_mode: String -- select: wysiwyg | markdown | preview + diff_view_style: String -- select: inline | side-by-side + wrap_long_lines: Boolean -- checkbox + hide_unchanged_regions: Boolean -- checkbox +} + +value SettingsCategoryRow { + name: String -- read-only for protected categories + title: String -- editable + render_in_lists: Boolean -- checkbox + show_titles: Boolean -- checkbox + post_template_slug: String? -- select + list_template_slug: String? -- select + is_protected: Boolean -- true for: article, aside, page, picture +} + +value SettingsAISection { + anthropic_api_key: String? -- masked + Change/Save + mistral_api_key: String? -- masked + Change/Save + ollama_enabled: Boolean -- toggle, shows model capabilities table (tools/vision) + lm_studio_enabled: Boolean -- toggle, shows model capabilities table + offline_mode: Boolean -- toggle, switches between online and airplane endpoint + default_model: String? -- select from active endpoint models + title_model: String? -- select + image_analysis_model: String? -- select (vision-capable only) + system_prompt: String -- textarea (12 rows) + Save + Reset to Default +} + +value SettingsPublishingSection { + ssh_mode: String -- select: scp | rsync + ssh_host: String -- text input + ssh_username: String -- text input + ssh_remote_path: String -- text input +} + +value SettingsMCPSection { + status: String -- port number or "Not running" + agents: List +} + +value MCPAgentRow { + agent_name: String -- Claude Code, Claude Desktop, GitHub Copilot, etc. + is_installed: Boolean -- Add/Remove toggle +} + +invariant SettingsProtectedCategories { + -- Protected categories (article, aside, page, picture) cannot be deleted. + -- Their Remove button is disabled. +} + +invariant SettingsMCPAgents { + -- MCP section has exactly 7 agent rows in this order: + -- Claude Code, Claude Desktop, GitHub Copilot, Gemini CLI, + -- OpenCode, Mistral Vibe, OpenAI Codex. +} + +config { + settings_max_posts_per_page: Integer = 500 + settings_default_posts_per_page: Integer = 50 + settings_system_prompt_rows: Integer = 12 +} + +surface SettingsViewSurface { + context settings: SettingsView + + exposes: + settings.search_query + settings.active_sections + + provides: + SettingsSearchChanged(query) + SettingsProjectSaved(project_data) + SettingsEditorSaved(editor_data) + SettingsCategoryAdded(category_name) + SettingsCategoryUpdated(category_row) + SettingsCategoryRemoved(category_name) + SettingsCategoriesResetToDefaults() + SettingsAIApiKeySaved(provider, key) + SettingsAIModelRefreshRequested(endpoint) + SettingsAISystemPromptSaved(prompt) + SettingsAISystemPromptReset() + SettingsPublishingSaved(publishing_data) + SettingsPublishingCleared() + SettingsMCPAgentToggled(agent_name) + SettingsRebuildRequested(entity_type) + SettingsRegenerateThumbnailsRequested() + SettingsOpenDataFolderRequested() + StyleThemeSelected(theme_name) + StyleApplyRequested(theme_name) + + @guarantee SearchBar + -- Search input in header filters sections by keyword match. + -- "No results" message with clear button when no sections match. + + @guarantee ProjectSection + -- Section 1: Project Name, Description (textarea 3 rows), Data Path (text + Browse + Reset), + -- Public URL, Main Language (select), Blog Languages (checkbox grid, main disabled), + -- Default Author, Max Posts Per Page (number 1-500), + -- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save button. + + @guarantee BookmarkletCopy + -- Copy button copies bookmarklet JavaScript to clipboard. + -- Bookmarklet uses project's publicUrl to construct POST endpoint. + + @guarantee EditorSection + -- Section 2: Default Editor Mode (select: WYSIWYG/Markdown/Preview), + -- Diff View Style (select: Inline/Side-by-side), + -- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox). + + @guarantee ContentCategoriesSection + -- Section 3: Table with columns: Category, Title, Render in Lists, + -- Show Titles, Post Template, List Template, Remove. + -- Protected categories (article, aside, page, picture) cannot be deleted. + -- Add Category: text input + Add button, validates non-empty and unique. + -- "Reset to Defaults" restores default categories set. + + @guarantee AISection + -- Section 4: Anthropic API key, Mistral API key (both masked + Change/Save), + -- Ollama toggle (with model capabilities table: tools/vision), + -- LM Studio toggle (with capabilities table), + -- Offline mode toggle (with dedicated model selectors for chat/title/image), + -- Default model (with catalog info: max output, context window), + -- Title model, Image analysis model, + -- System prompt (textarea config.settings_system_prompt_rows rows + Save + Reset). + + @guarantee AIApiKeyValidation + -- On Save: test API call to endpoint before persisting. + -- On failure: toast error message, key not saved. + -- On success: key encrypted via SecureKeyStore, masked display shown. + + @guarantee AIModelRefresh + -- Refresh Models button per endpoint fetches model list from URL. + -- Model selector populated from fetched list. + -- Per-model info: max output tokens, context window (when available). + + @guarantee TechnologySection + -- Section 5: scripting capabilities are configured at the application level; + -- this section does not expose implementation-specific runtime choices. + -- Semantic Similarity toggle. + + @guarantee PublishingSection + -- Section 6: SSH Mode (scp/rsync), Host, Username, Remote Path. + -- Save + Clear buttons. + + @guarantee MCPSection + -- Section 7: Status badge (port or "Not running"). + -- 7 agent rows with Add/Remove toggle each. + + @guarantee DataMaintenanceSection + -- Section 8: 6 rebuild buttons (Posts from Files, Media from Files, + -- Scripts from Files, Templates from Files, Links, + -- Regenerate Missing Thumbnails). + -- Open Data Folder button. + -- Each rebuild executes immediately (no confirmation). + -- Runs as background task with progress in Tasks panel. + + @guarantee CollapsibleSections + -- All 8 sections are collapsible. + -- Section visibility respects search filter. +} + +-- ─── Settings view actions ────────────────────────────────── + +rule SettingsRebuild { + when: SettingsRebuildRequested(entity_type) + -- entity_type: posts | media | scripts | templates | links | thumbnails + -- Executes immediately (no confirmation dialog) + -- Runs as background task with progress visible in Tasks panel + -- On completion: wholesale replaces store data for that entity type + -- Sidebar and editors re-render with fresh data +} + +-- ─── Style view ─────────────────────────────────────────────── + +value StyleView { + themes: List + selected_theme: String? + applied_theme: String? + preview_mode: String -- auto | light | dark +} + +value StyleTheme { + name: String + accent_color: String -- hex + light_bg_color: String -- hex + dark_bg_color: String -- hex +} + +surface StyleViewSurface { + context style: StyleView + + exposes: + for t in style.themes: + t.name + t.accent_color + t.light_bg_color + t.dark_bg_color + style.selected_theme + style.applied_theme + style.preview_mode + + provides: + StyleThemeSelected(theme_name) + StyleApplyRequested(style.selected_theme) + when style.selected_theme != style.applied_theme + StylePreviewModeChanged(mode) + + @guarantee ThemePicker + -- Grid of theme buttons (one per Pico CSS theme). + -- Each button: swatch with 3 colour tones (accent, light bg, dark bg) + theme name. + -- Selected theme highlighted with aria-pressed. + + @guarantee ControlsRow + -- Preview Mode dropdown (Auto/Light/Dark). + -- Apply Theme button, disabled when selected_theme matches applied_theme. + + @guarantee LivePreview + -- Iframe at 127.0.0.1:4123/__style-preview with theme and mode query params. + -- Updates live on selection or preview mode change. + + @guarantee PreviewModeLocal + -- Preview mode dropdown controls iframe query param only. + -- Does not persist — local UI state only. +} + +rule StyleThemeSelect { + when: StyleThemeSelected(theme_name) + -- Updates iframe preview immediately (query param change) + -- Does NOT persist until Apply is clicked +} + +rule StyleApplyTheme { + when: StyleApplyRequested(theme_name) + -- Persists picoTheme to project metadata (meta/project.json) + -- See engine_side_effects.allium UpdateProjectMetadataSideEffects +} diff --git a/specs/editor_tags.allium b/specs/editor_tags.allium new file mode 100644 index 0000000..394cac6 --- /dev/null +++ b/specs/editor_tags.allium @@ -0,0 +1,118 @@ +-- allium: 1 +-- bDS Tags View +-- Scope: UI content area — tag management surface +-- Distilled from: TagsView.tsx + +-- Describes the layout and behaviour of the tags view. + +use "./tag.allium" as tag +use "./i18n.allium" as i18n + +-- ─── Tags view ──────────────────────────────────────────────── + +value TagsView { + cloud_tags: List + selected_tags: List -- multi-select from cloud + edit_form: TagEditForm? -- populated when exactly 1 tag selected + merge_source_tags: List -- 2+ selected tags for merge + merge_target: String? -- target tag for merge +} + +value TagCloudItem { + name: String + count: Integer + color: String? +} + +value TagEditForm { + name: String -- editable text input + color: String? -- colour picker value + post_template_slug: String? -- select from available templates +} + +value ColourPickerPopover { + presets: List -- 17 hex colour values + custom_hex: String? + selected: String? +} + +surface ColourPickerPopoverSurface { + context popover: ColourPickerPopover + + exposes: + popover.presets + popover.custom_hex when popover.custom_hex != null + popover.selected when popover.selected != null +} + +config { + colour_picker_preset_count: Integer = 17 + tag_cloud_min_font: String = "0.85rem" + tag_cloud_max_font: String = "1.8rem" +} + +surface TagsViewSurface { + context view: TagsView + + exposes: + for t in view.cloud_tags: + t.name + t.count + t.color + view.selected_tags + view.edit_form when view.edit_form != null + view.merge_target when view.merge_target != null + + provides: + TagCloudItemClicked(tag_name) + TagCreateRequested(name, color) + TagSaveRequested(name, color, post_template_slug) + when view.edit_form != null + TagDeleteRequested(tag_name) + when view.selected_tags.count = 1 + TagMergeRequested(source_tags, target_tag) + when view.selected_tags.count >= 2 + TagSyncRequested() + + @guarantee CloudSection + -- Tag cloud visualisation (section #1). + -- Tags sized by post count (config.tag_cloud_min_font to config.tag_cloud_max_font). + -- Tags with colours get coloured background with contrast text. + -- Multi-select: click to toggle selection. Selection count shown with clear button. + -- Click selects tag for editing in Manage section. + + @guarantee ManageSection + -- Create/Edit section (section #2). + -- Create form: name input + colour picker + Create button. + -- Edit form (when exactly 1 tag selected): name input, colour picker, + -- post template select dropdown (optional), Save/Cancel buttons. + -- Delete button visible when exactly 1 tag selected. + + @guarantee ColourPicker + -- Inline popover positioned below tag colour field. + -- Grid of config.colour_picker_preset_count preset colour swatches. + -- Below grid: custom hex input field (#RRGGBB). + -- Selection is immediate — no confirm/cancel buttons. + -- Choosing a colour updates the form's colour field live. + + @guarantee MergeSection + -- Merge section (section #3). Requires 2+ selected tags. + -- Target tag select dropdown (single select from selected tags). + -- Merge button opens ConfirmDialog: + -- "Merge N tags into {target}? This cannot be undone." + -- Shows preview of "tags to delete" (all selected except target). + + @guarantee SyncSection + -- Sync/Discover section (section #4). + -- "Discover" button rewrites tags.json from current DB state. + + @guarantee DeleteConfirmation + -- Delete opens ConfirmDialog showing post count: + -- "This tag is used in N posts. Delete anyway?" + -- On confirm: background task removes tag from all posts, + -- rewrites published .md files, deletes tag, writes tags.json. + + @guarantee MergeExecution + -- On merge confirm: background task updates all affected posts, + -- rewrites published .md files, deletes source tags, writes tags.json. +} diff --git a/specs/editor_template.allium b/specs/editor_template.allium new file mode 100644 index 0000000..b661f30 --- /dev/null +++ b/specs/editor_template.allium @@ -0,0 +1,87 @@ +-- allium: 1 +-- bDS Template Editor View +-- Scope: UI content area — template editing surface +-- Distilled from: TemplateEditor.tsx + +-- Describes the layout and behaviour of the template editor. +-- See template.allium for entity model and Liquid subset. + +use "./template.allium" as template +use "./i18n.allium" as i18n + +-- ─── Template editor view ───────────────────────────────────── + +value TemplateEditorView { + template_id: String + title: String -- editable text input + slug: String -- editable text input, auto-generated from title + kind: String -- select: post | list | not_found | partial + enabled: Boolean -- checkbox + content: String -- code editor content + created_at: String -- locale-formatted date + updated_at: String -- locale-formatted date +} + +surface TemplateEditorSurface { + context editor: TemplateEditorView + + exposes: + editor.title + editor.slug + editor.kind + editor.enabled + editor.created_at + editor.updated_at + + provides: + TemplateSaveRequested(editor.template_id) + TemplateValidateRequested(editor.template_id) + TemplateDeleteRequested(editor.template_id) + + @guarantee HeaderLayout + -- Header bar with template title tab. + -- Actions (right side): Save button, Validate button, + -- Delete button (danger style). + + @guarantee MetadataRow + -- Two rows of metadata fields above the editor. + -- Row 1: Title (text input), Slug (text input, auto-generated from title). + -- Row 2: Kind (select: post/list/not-found/partial), Enabled (checkbox). + + @guarantee EditorBody + -- Code editor with HTML/Liquid syntax highlighting. + -- Toolbar: "Content" label. + -- Syntax errors shown inline in editor on validation. + + @guarantee FooterLayout + -- Created date and Updated date, locale-formatted. +} + +-- ─── Template editor actions ──────────────────────────────── + +rule TemplateSave { + when: TemplateSaveRequested(template_id) + -- Liquid validation first (parse check for syntax errors) + -- If invalid: blocks save, shows error inline in editor + -- If valid: bumps version, saves to DB + rewrites .liquid file + -- See engine_side_effects.allium UpdateTemplateSideEffects +} + +rule TemplateValidate { + when: TemplateValidateRequested(template_id) + -- Validates Liquid syntax without saving + -- Shows errors inline in editor + -- Success shown as toast or status indicator +} + +rule TemplateDelete { + when: TemplateDeleteRequested(template_id) + -- Checks for references: posts using this template, tags with postTemplateSlug + -- If references exist: system confirm dialog + -- "This template is used by N posts and M tags. Force delete?" + -- Force delete: nulls templateSlug on referencing posts, + -- nulls postTemplateSlug on referencing tags + -- If no references: deletes without confirmation + -- Deletes DB record + .liquid file on disk + -- Closes template tab, sidebar removes item +} diff --git a/specs/embedding.allium b/specs/embedding.allium new file mode 100644 index 0000000..aeb68ec --- /dev/null +++ b/specs/embedding.allium @@ -0,0 +1,226 @@ +-- allium: 1 +-- bDS Semantic Similarity / Embeddings +-- Scope: extension (Bucket D — Embeddings + Duplicate Detection) +-- Distilled from: src/main/engine/EmbeddingEngine.ts + +-- Local embedding model for semantic similarity. Runs entirely on-device, +-- independent of AI endpoints. Gated by semanticSimilarityEnabled project setting. + +use "./post.allium" as post +use "./tag.allium" as tag + +surface EmbeddingModelSurface { + context model: EmbeddingModel + + exposes: + model.model_id + model.dimensions +} + +surface EmbeddingRuntimeSurface { + facing _: EmbeddingRuntime + + provides: + PostCreated(post) + PostUpdated(post) + PostDeleted(post) +} + +surface EmbeddingControlSurface { + facing _: EmbeddingOperator + + provides: + ReindexAllRequested(project) + IndexUnindexedRequested(project) + FindSimilarRequested(post, limit) + ComputeSimilaritiesRequested(source_post, target_post_ids) + SuggestTagsRequested(post, input_text) + FindDuplicatesRequested(project) + DismissDuplicatePairRequested(post_a, post_b) +} + +-- ─── Model ────────────────────────────────────────────────── + +value EmbeddingModel { + -- multilingual-e5-small: 384-dimensional sentence embeddings + -- Model files are obtained from an external model source and cached locally + -- Downloaded on first use, cached in app data directory + -- Lazy-loaded: pipeline created on first embedding request, not at startup + -- Text preprocessing: prefix all input with "query: " (e5 convention) + -- Pooling: mean pooling + L2 normalization + model_id: String -- "Xenova/multilingual-e5-small" + dimensions: Integer -- 384 +} + +value EmbeddingVector { + dimensions: Integer -- 384 (multilingual-e5-small) + values: List +} + +-- ─── Entities ─────────────────────────────────────────────── + +entity EmbeddingKey { + label: Integer -- HNSW label for USearch + post: post/Post + content_hash: String -- SHA-256 of "{title}\n\n{content}" + vector: EmbeddingVector +} + +entity DismissedDuplicatePair { + post_a: post/Post + post_b: post/Post + -- IDs stored in canonical order (sorted) for dedup +} + +-- ─── USearch HNSW Index ───────────────────────────────────── + +config { + model_id: String = "Xenova/multilingual-e5-small" + embedding_dimensions: Integer = 384 + hnsw_metric: String = "cosine" + hnsw_connectivity: Integer = 16 -- M parameter + hnsw_expansion_add: Integer = 128 -- efConstruction + hnsw_expansion_search: Integer = 64 -- efSearch + debounce_persist: Duration = 5.seconds + -- Index file: {userData}/projects/{projectId}/embeddings.usearch + -- Key mapping is persisted alongside the embedding records +} + +-- ─── Gating ───────────────────────────────────────────────── + +invariant SemanticSimilarityGate { + -- All embedding operations are gated by semanticSimilarityEnabled in project metadata. + -- When disabled: no posts are indexed, queries return empty results. + -- When toggled on: triggers IndexUnindexed to backfill all posts. + -- When toggled off: index remains on disk but is not queried. +} + +-- ─── Event-driven indexing ────────────────────────────────── + +-- Post lifecycle events trigger embedding updates automatically. +-- See engine_side_effects.allium for the trigger points. + +rule EmbedPost { + when: PostCreated(post) or PostUpdated(post) + requires: semantic_similarity_enabled + let hash = sha256(format("{title}\n\n{content}", title: post.title, content: post.content)) + let existing = EmbeddingKey{post: post} + if not exists existing or existing.content_hash != hash: + -- Compute embedding vector via local model + -- Upsert into USearch index + embedding_keys DB table + -- Debounced index save (5s) + ensures: EmbeddingKeyUpdated(post) +} + +rule RemovePostEmbedding { + when: PostDeleted(post) + requires: semantic_similarity_enabled + ensures: EmbeddingKeyRemoved(post) +} + +-- ─── Batch indexing ───────────────────────────────────────── + +rule ReindexAll { + when: ReindexAllRequested(project) + requires: semantic_similarity_enabled + -- Re-embeds all posts, rebuilds HNSW index from scratch + for p in project.posts: + ensures: EmbeddingKeyUpdated(p) + ensures: HnswIndexRebuilt(project) +} + +rule IndexUnindexed { + when: IndexUnindexedRequested(project) + requires: semantic_similarity_enabled + -- Triggered at app startup (if enabled) and when setting toggled on + -- Only embeds posts without existing embeddings or with changed content_hash + -- Runs as background task with progress reporting + for p in project.posts: + let existing = EmbeddingKey{post: p} + if not exists existing or existing.content_hash != p.checksum: + ensures: EmbeddingKeyUpdated(p) +} + +-- ─── Query operations ─────────────────────────────────────── + +rule FindSimilar { + when: FindSimilarRequested(post, limit) + requires: semantic_similarity_enabled + -- HNSW approximate nearest neighbor search via USearch + -- Searches index for (limit + 1) neighbors, excludes self + -- Converts USearch cosine distance to similarity: max(0, 1 - distance) + -- Returns ranked list sorted by descending similarity + ensures: SimilarPostsResult(post, ranked_matches) +} + +rule ComputeSimilarities { + when: ComputeSimilaritiesRequested(source_post, target_post_ids) + requires: semantic_similarity_enabled + -- Exact pairwise cosine similarity between source vector and each target vector + -- Uses in-memory vector cache, NOT USearch search + -- Returns map of post_id -> similarity score + -- Used by InsertPostLinkModal to rank FTS search results + ensures: SimilarityScoresResult(source_post, scores) +} + +rule SuggestTags { + when: SuggestTagsRequested(post, input_text) + requires: semantic_similarity_enabled + -- 1. Find 10 most similar posts via HNSW search + -- 2. Collect all tags from those posts + -- 3. Weight tags by similarity score of the post they came from + -- 4. Return top 5 tags by weighted score + -- Used by tag input component for autocomplete suggestions + ensures: TagSuggestionResult(post, suggested_tags) +} + +rule FindDuplicates { + when: FindDuplicatesRequested(project) + requires: semantic_similarity_enabled + -- Finds near-duplicate post pairs above similarity threshold + -- For each indexed post: search 21 nearest neighbors + -- Pairs above 0.92 threshold kept, dismissed pairs excluded + -- At 100% embedding similarity: loads post bodies for exact match check + -- Results sorted: exact matches first, then descending similarity + let all_pairs = compute_all_similarities(project) + let above_threshold = filter_above_threshold(all_pairs) + let pairs = exclude_dismissed(above_threshold, DismissedDuplicatePairs) + ensures: DuplicateReport(pairs) +} + +rule DismissDuplicatePair { + when: DismissDuplicatePairRequested(post_a, post_b) + -- Stores with canonical ID ordering for consistent dedup + ensures: DismissedDuplicatePair.created(post_a: post_a, post_b: post_b) +} + +-- ─── Invariants ───────────────────────────────────────────── + +invariant ContentHashSkipsUnchanged { + -- If a post's content_hash matches the stored embedding's content_hash, + -- the post is not re-embedded. This makes bulk re-indexing efficient. +} + +invariant DebouncedPersistence { + -- USearch index persistence is debounced at 5 seconds + -- Prevents excessive disk I/O during bulk operations + -- Index also force-saved on project switch and app shutdown +} + +invariant VectorCacheInDb { + -- Vector cache persisted as BLOB in embedding_keys table + -- Float32Array, 384 dimensions per vector (1536 bytes) + -- Enables instant reload without re-embedding +} + +invariant ModelCaching { + -- Model files (~100 MB) downloaded from Hugging Face Hub on first use + -- Cached in app data directory, persists across sessions + -- Model pipeline stays loaded across project switches (one model, many indexes) +} + +invariant ProjectIsolation { + -- Each project has its own USearch index file and embedding_keys rows + -- On project switch: save current index, load new project's index + -- Model pipeline shared across projects (not reloaded) +} diff --git a/specs/engine_side_effects.allium b/specs/engine_side_effects.allium new file mode 100644 index 0000000..e3bc34f --- /dev/null +++ b/specs/engine_side_effects.allium @@ -0,0 +1,314 @@ +-- allium: 1 +-- bDS Engine-Level Save Side-Effects +-- Scope: cross-cutting (all waves) +-- Distilled from: PostEngine.ts, MediaEngine.ts, TemplateEngine.ts, +-- ScriptEngine.ts, MetaEngine.ts, TagEngine.ts + +-- When an entity is saved/published/deleted in the engine layer, a chain +-- of automatic side-effects fires. These are NOT UI-level concerns — +-- they happen in the backend regardless of which UI triggered them. + +use "./post.allium" as post +use "./media.allium" as media + +-- ─── External surfaces ────────────────────────────────────── + +-- Engine-level events emitted after backend operations complete. +-- These are NOT direct user actions — they fire as side-effects +-- of user operations processed by the engine layer. + +surface Engine { + provides: PostCreated(post) + provides: PostUpdated(post, changes) + provides: PostPublished(post) + provides: PostDeleted(post) + provides: PostChangesDiscarded(post) + provides: MediaImported(media) + provides: MediaUpdated(media, changes) + provides: MediaFileReplaced(media, new_file) + provides: MediaDeleted(media) + provides: TemplateCreated(template) + provides: TemplateUpdated(template, changes) + provides: TemplatePublished(template) + provides: TemplateDeleted(template, force) + provides: TagDeleted(tag) + provides: TagRenamed(old_name, new_name) + provides: TagsMerged(source_tags, target_tag) + provides: ProjectMetadataUpdated(metadata) + provides: CategoryAdded(name) + provides: CategoryRemoved(name) + provides: PublishingPreferencesUpdated(prefs) + provides: PostTranslationUpserted(translation, source_post) + provides: MediaTranslationUpserted(translation, media) + provides: MediaTranslationDeleted(media, language) +} + +-- ─── Post operations ───────────────────────────────────── + +rule CreatePostSideEffects { + when: PostCreated(post) + ensures: FTSIndexUpdated(post) + ensures: EmbeddingUpdated(post) + -- No file written (draft lives in DB) +} + +rule UpdatePostSideEffects { + when: PostUpdated(post, changes) + -- If post is published and content/metadata changes: + -- auto-transition status back to draft + -- If slug changed and file exists: rename .md file + -- If templateSlug changed on published post: rewrite .md frontmatter + ensures: FTSIndexUpdated(post) + if changes.content: + ensures: PostLinksUpdated(post) + -- Parses markdown/HTML links, resolves slugs to post IDs, + -- replaces outgoing link rows + ensures: EmbeddingUpdated(post) +} + +rule PublishPostSideEffects { + when: PostPublished(post) + ensures: PostFileWritten(post) + -- posts/YYYY/MM/{slug}.md with YAML frontmatter + if old_file_path != new_file_path: + ensures: OldPostFileDeleted(old_file_path) + ensures: post.content = null + -- Content cleared from DB; lives in filesystem only + ensures: FTSIndexUpdated(post) + ensures: PostLinksUpdated(post) + -- Also publishes all translations: + for t in post.translations: + ensures: TranslationFileWritten(t) + ensures: t.content = null + ensures: EmbeddingUpdated(post) +} + +rule DeletePostSideEffects { + when: PostDeleted(post) + if post.file_path != "": + ensures: PostFileDeleted(post.file_path) + ensures: PostLinksDeleted(post) + -- Deletes both source and target link rows + for media_link in post.linked_media: + ensures: MediaSidecarUpdated(media_link.media_id) + -- Removes post from media sidecar's linkedPostIds + ensures: FTSIndexDeleted(post) + ensures: EmbeddingRemoved(post) +} + +rule DiscardPostChangesSideEffects { + when: PostChangesDiscarded(post) + -- Reads published version from file, restores DB metadata, + -- sets content=null, status=published + ensures: FTSIndexUpdated(post) +} + +-- ─── Media operations ──────────────────────────────────── + +rule ImportMediaSideEffects { + when: MediaImported(media) + ensures: MediaFileWritten(media) + -- media/YYYY/MM/{uuid}.{ext} + ensures: SidecarFileWritten(media) + -- {path}.meta with YAML-like metadata + if media.is_image: + ensures: ThumbnailsGenerated(media) + -- small=150px, medium=400px, large=800px, ai=448x448 + -- Asynchronous, emits thumbnailsGenerated on completion + ensures: FTSIndexUpdated(media) +} + +rule UpdateMediaSideEffects { + when: MediaUpdated(media, changes) + ensures: SidecarFileRewritten(media) + -- Preserves fields caller didn't set (linkedPostIds, author) + ensures: FTSIndexUpdated(media) +} + +rule ReplaceMediaFileSideEffects { + when: MediaFileReplaced(media, new_file) + -- Copies new file over existing path + if media.is_image: + ensures: ThumbnailsRegenerated(media) + -- Synchronous (awaited), not fire-and-forget +} + +rule DeleteMediaSideEffects { + when: MediaDeleted(media) + ensures: MediaFileDeleted(media) + ensures: SidecarFileDeleted(media) + ensures: ThumbnailsDeleted(media) + ensures: PostMediaLinksDeleted(media) + ensures: MediaTranslationsDeleted(media) + -- Also deletes all translated sidecar files: {path}.{lang}.meta + ensures: FTSIndexDeleted(media) +} + +-- ─── Template operations ───────────────────────────────── + +rule CreateTemplateSideEffects { + when: TemplateCreated(template) + ensures: TemplateFileWritten(template) + -- templates/{slug}.liquid with YAML frontmatter +} + +rule UpdateTemplateSideEffects { + when: TemplateUpdated(template, changes) + ensures: template.version = template.version + 1 + -- DB-first update, then filesystem; rollback DB on filesystem failure + if changes.slug: + ensures: TemplateFileRenamed(template) + ensures: CascadeSlugUpdate(template) + -- Updates posts.templateSlug and tags.postTemplateSlug + ensures: TemplateFileRewritten(template) +} + +rule PublishTemplateSideEffects { + when: TemplatePublished(template) + ensures: TemplateFileWritten(template) + ensures: template.content = null + -- Content cleared from DB +} + +rule DeleteTemplateSideEffects { + when: TemplateDeleted(template, force) + if has_references and not force: + -- Return without deleting, report reference counts + ensures: nothing + if force: + ensures: ReferencingPostsCleared(template) + ensures: ReferencingTagsCleared(template) + -- Nulls out templateSlug on posts, postTemplateSlug on tags + ensures: TemplateFileDeleted(template) +} + +-- ─── Script operations ─────────────────────────────────── + +-- Same pattern as templates: +-- Create: write published script file, insert DB +-- Update: bump version, rewrite file, update DB +-- Publish: write file, clear DB content +-- Delete: delete file, delete DB row + +-- ─── Tag operations ────────────────────────────────────── + +rule DeleteTagSideEffects { + when: TagDeleted(tag) + -- Background task: + -- For each post containing this tag: + -- Remove tag from post's tags array in DB + -- If published: rewrite .md file (syncPublishedPostFile) + ensures: TagsJsonWritten() + -- meta/tags.json updated +} + +rule RenameTagSideEffects { + when: TagRenamed(old_name, new_name) + -- Background task: + -- For each post containing old_name: + -- Replace old_name with new_name in tags array + -- If published: rewrite .md file + ensures: TagsJsonWritten() +} + +rule MergeTagsSideEffects { + when: TagsMerged(source_tags, target_tag) + -- Background task: + -- For each source tag, for each post containing it: + -- Replace source with target (dedup), update DB + -- If published: rewrite .md file + -- Delete all source tag rows + ensures: TagsJsonWritten() +} + +-- ─── Settings/Metadata operations ──────────────────────── + +rule UpdateProjectMetadataSideEffects { + when: ProjectMetadataUpdated(metadata) + ensures: ProjectJsonWritten() + -- meta/project.json (atomic write) + ensures: CategoryMetaJsonWritten() + -- meta/category-meta.json (atomic write) +} + +rule AddCategorySideEffects { + when: CategoryAdded(name) + ensures: ProjectJsonWritten() + ensures: CategoryMetaJsonWritten() + ensures: CategoriesJsonWritten() + -- meta/categories.json +} + +rule RemoveCategorySideEffects { + when: CategoryRemoved(name) + ensures: ProjectJsonWritten() + ensures: CategoryMetaJsonWritten() + ensures: CategoriesJsonWritten() +} + +rule UpdatePublishingPreferencesSideEffects { + when: PublishingPreferencesUpdated(prefs) + ensures: PublishingJsonWritten() + -- meta/publishing.json (atomic write) +} + +-- ─── Translation operations ────────────────────────────── + +rule UpsertPostTranslationSideEffects { + when: PostTranslationUpserted(translation, source_post) + -- If source is published and this is a manual edit (not auto-publish): + -- transition source post to draft (copies content from file to DB) + if source_post.status = published and translation.is_manual_edit: + ensures: source_post.status = draft + ensures: source_post.content = read_file(source_post.file_path) + -- If both translation and source are published: + -- write translation file, clear translation content from DB + if translation.status = published and source_post.status = published: + ensures: TranslationFileWritten(translation) + ensures: translation.content = null + ensures: FTSIndexUpdated(source_post) + -- FTS includes all translation content for the source post +} + +rule UpsertMediaTranslationSideEffects { + when: MediaTranslationUpserted(translation, media) + ensures: TranslatedSidecarWritten(media, translation.language) + -- {path}.{lang}.meta +} + +rule DeleteMediaTranslationSideEffects { + when: MediaTranslationDeleted(media, language) + ensures: TranslatedSidecarDeleted(media, language) +} + +-- ─── CLI/MCP notification sync ─────────────────────────── + +-- When MCP CLI makes mutations, it writes to db_notifications table. +-- NotificationWatcher polls DB file (chokidar, 100ms debounce): +-- Reads unseen CLI notifications +-- Calls engine.invalidate(entityId) if needed +-- Sends entity:changed IPC event to renderer +-- Marks rows as seen +-- Prunes: >1h processed, >24h unprocessed + +-- ─── Side-effect summary table ─────────────────────────── + +-- Operation | File Write | FTS | Links | Thumbs | Sidecar | Embed | JSON Meta +-- -------------------|--------------|------|-------|--------|---------|-------|---------- +-- createPost | no (draft) | yes | no | no | no | yes | no +-- updatePost | rename only* | yes | if Δ | no | no | yes | no +-- publishPost | .md + trans | yes | yes | no | no | yes | no +-- deletePost | delete .md | del | del | no | Δ media | del | no +-- importMedia | copy file | yes | no | async | write | no | no +-- updateMedia | no | yes | no | no | rewrite | no | no +-- replaceMediaFile | overwrite | no | no | regen | no | no | no +-- deleteMedia | delete all | del | no | del | del all | no | no +-- createTemplate | .liquid | no | no | no | no | no | no +-- updateTemplate | rewrite | no | no | no | no | no | no +-- deleteTemplate | delete+casc | no | no | no | no | no | no +-- deleteTag | sync posts | no | no | no | no | no | tags.json +-- renameTag | sync posts | no | no | no | no | no | tags.json +-- mergeTags | sync posts | no | no | no | no | no | tags.json +-- updateMetadata | no | no | no | no | no | no | *.json +-- addCategory | no | no | no | no | no | no | *.json +-- * updatePost rewrites file only when templateSlug changes on published post diff --git a/specs/frontmatter.allium b/specs/frontmatter.allium new file mode 100644 index 0000000..179a546 --- /dev/null +++ b/specs/frontmatter.allium @@ -0,0 +1,394 @@ +-- allium: 1 +-- bDS Frontmatter Specifications +-- Scope: core (Wave 1 — exact file format compatibility) +-- Distilled from: ../bDS/src/main/engine/postFileUtils.ts, +-- TemplateEngine.ts, ScriptEngine.ts, MediaEngine.ts +-- +-- This document specifies the exact YAML frontmatter format for all +-- file types. The rewrite must read and write these formats compatibly +-- with existing bDS content. + +surface FrontmatterPersistenceSurface { + facing _: ContentPersistenceRuntime + + provides: + PublishPostRequested(post) + PublishTemplateRequested(template) + PublishScriptRequested(script) +} + +surface PostFrontmatterSurface { + context frontmatter: PostFrontmatter + + exposes: + frontmatter.id + frontmatter.title + frontmatter.slug + frontmatter.status + frontmatter.published_at + frontmatter.tags + frontmatter.categories +} + +surface MediaSidecarSurface { + context sidecar: MediaSidecar + + exposes: + sidecar.id + sidecar.original_name + sidecar.mime_type + sidecar.width + sidecar.height + sidecar.updated_at +} + +surface TemplateFrontmatterSurface { + context frontmatter: TemplateFrontmatter + + exposes: + frontmatter.id + frontmatter.slug + frontmatter.kind + frontmatter.enabled + frontmatter.version +} + +surface ScriptFrontmatterSurface { + context frontmatter: ScriptFrontmatter + + exposes: + frontmatter.id + frontmatter.slug + frontmatter.kind + frontmatter.entrypoint + frontmatter.enabled + frontmatter.version +} + +surface MenuOpmlSurface { + context document: MenuOpml + + exposes: + document.header.title + document.header.date_created + document.header.date_modified + for item in document.body: + item.kind + item.label + item.slug +} + +config { + script_extension: String = "script" +} + +-- ============================================================================ +-- POST FILE FORMAT +-- ============================================================================ + +value PostFrontmatter { + -- File path: posts/{YYYY}/{MM}/{slug}.md + -- For translations: posts/{YYYY}/{MM}/{slug}.{language}.md + id: String -- UUID v4 + title: String + slug: String + excerpt: String? -- Optional, only written if present + status: draft | published | archived + author: String? -- Only written if present + language: String? -- Only written if present (ISO 639-1) + do_not_translate: Boolean -- Only written when true + template_slug: String? -- Only written if present + created_at: Timestamp -- Unix timestamp in milliseconds + updated_at: Timestamp -- Unix timestamp in milliseconds + published_at: Timestamp? -- Only written if published + tags: List -- Always written, even if empty + categories: List -- Always written, even if empty +} + +invariant PostFileLayout { + -- Posts are stored in date-based directory structure + -- YYYY and MM derived from created_at (zero-padded) + for p in Posts where file_path != "": + p.file_path = format("posts/{yyyy}/{mm}/{slug}.md", + yyyy: p.created_at.year, + mm: p.created_at.month_padded, + slug: p.slug) +} + +invariant PostTranslationFileLayout { + -- Translations use the same directory structure with language suffix + for t in PostTranslations where file_path != "": + t.file_path = format("posts/{yyyy}/{mm}/{slug}.{lang}.md", + yyyy: t.canonical_post.created_at.year, + mm: t.canonical_post.created_at.month_padded, + slug: t.canonical_post.slug, + lang: t.language) +} + +rule WritePostFile { + when: PublishPostRequested(post) + ensures: FileWritten( + path: post.file_path, + content: format_post_file(post) + ) + ensures: post.content = null + -- Content moved from DB to filesystem +} + +-- ============================================================================ +-- MEDIA SIDECAR FORMAT +-- ============================================================================ + +value MediaSidecar { + -- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta) + -- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext} + -- Format: YAML-like key-value (hand-built, not gray-matter frontmatter) + -- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path + id: String -- UUID v4 + original_name: String -- Original uploaded filename + mime_type: String + size: Integer -- Bytes + width: Integer? + height: Integer? + title: String? -- Only written if present + alt: String? -- Only written if present + caption: String? -- Only written if present + author: String? -- Only written if present + language: String? -- Only written if present + tags: List -- Always written, even if empty + created_at: Timestamp + updated_at: Timestamp +} + +invariant MediaSidecarLayout { + for m in Media: + m.sidecar_path = format("{binary_path}.meta", binary_path: m.file_path) +} + +-- ============================================================================ +-- TEMPLATE FILE FORMAT +-- ============================================================================ + +value TemplateFrontmatter { + -- File path: templates/{slug}.liquid + id: String -- UUID v4 + slug: String + title: String + kind: post | list | not_found | partial + enabled: Boolean + version: Integer + created_at: Timestamp + updated_at: Timestamp +} + +rule WriteTemplateFile { + when: PublishTemplateRequested(template) + requires: ValidateLiquid(template.content) = valid + ensures: FileWritten( + path: format("templates/{slug}.liquid", slug: template.slug), + content: format_template_file(template) + ) + ensures: template.content = null +} + +-- ============================================================================ +-- SCRIPT FILE FORMAT +-- ============================================================================ + +value ScriptFrontmatter { + -- File path: scripts/{slug}.{extension} + -- YAML frontmatter delimited by --- markers + id: String -- UUID v4 + slug: String + title: String + kind: macro | utility | transform + entrypoint: String -- Default: "render" + enabled: Boolean + version: Integer + created_at: Timestamp + updated_at: Timestamp +} + +rule WriteScriptFile { + when: PublishScriptRequested(script) + requires: ValidateScript(script.content) = valid + ensures: FileWritten( + path: format("scripts/{slug}.{extension}", slug: script.slug, extension: config.script_extension), + content: format_script_file(script) + ) + ensures: script.content = null +} + +-- ============================================================================ +-- TAGS FILE FORMAT +-- ============================================================================ + +value TagsFile { + -- File path: meta/tags.json + -- Portable JSON format (no internal IDs) + tags: List +} + +value TagEntry { + name: String + color: String? + post_template_slug: String? +} + +invariant TagsFileFormat { + -- Tags are stored as a sorted JSON array + -- Sorted alphabetically by name (case-insensitive) + parse_json(read_file("meta/tags.json")) = { + tags: sort_by(Tags, t => lowercase(t.name)) + } +} + +-- ============================================================================ +-- PROJECT METADATA FILES +-- ============================================================================ + +value ProjectJson { + -- File path: meta/project.json + name: String + description: String? + public_url: String? + main_language: String? + default_author: String? + max_posts_per_page: Integer + blogmark_category: String? + pico_theme: String? + semantic_similarity_enabled: Boolean + blog_languages: List +} + +value CategoriesJson { + -- File path: meta/categories.json + -- Sorted list of category names + categories: List +} + +value CategoryMetaJson { + -- File path: meta/category-meta.json + -- Per-category render settings + categories: Map +} + +value CategorySettings { + render_in_lists: Boolean + show_title: Boolean + post_template_slug: String? + list_template_slug: String? +} + +value PublishingJson { + -- File path: meta/publishing.json + ssh_host: String? + ssh_user: String? + ssh_remote_path: String? + ssh_mode: scp | rsync +} + +invariant MetadataFileLayout { + -- All metadata files in meta/ directory + -- Each file is written atomically (temp file + rename) + meta/project.json = serialize(ProjectJson) + meta/categories.json = serialize(CategoriesJson) + meta/category-meta.json = serialize(CategoryMetaJson) + meta/publishing.json = serialize(PublishingJson) + meta/menu.opml = serialize(Menu) + meta/tags.json = serialize(TagsFile) +} + +-- ============================================================================ +-- MENU FILE FORMAT +-- ============================================================================ + +value MenuOpml { + -- File path: meta/menu.opml + -- OPML 2.0 format with outline elements + header: OpmlHeader + body: List +} + +value OpmlHeader { + title: String + date_created: Timestamp + date_modified: Timestamp +} + +value MenuItem { + kind: page | submenu | category_archive | home + label: String + slug: String? + children: List? +} + +invariant MenuOpmlFormat { + -- Menu is stored as OPML with Home always first + -- Note: List literal syntax not supported in Allium + -- Actual structure: header + body with MenuItem elements +} + +-- ============================================================================ +-- FILE FORMAT CONVENTIONS +-- ============================================================================ + +invariant TimestampFormat { + -- Database: Unix milliseconds stored as INTEGER columns + -- YAML frontmatter: ISO 8601 strings (e.g. 2024-03-15T14:30:00.000Z) + -- Conversion on read: parse ISO 8601 → Unix ms + -- Conversion on write: Unix ms → ISO 8601 +} + +invariant YamlFormatting { + -- YAML frontmatter uses 2-space indentation + -- Arrays use YAML list syntax: - item1\n- item2 + -- Strings with special characters are quoted + -- Boolean values are lowercase: true/false +} + +invariant AtomicWrites { + -- All file writes are atomic + -- Write to temp file first, then rename + -- Prevents corruption from interrupted writes +} + +-- ============================================================================ +-- FRONTmatter FIELD RULES +-- ============================================================================ + +invariant RequiredPostFields { + -- These fields are ALWAYS written for posts + for p in Posts: + required_fields(p) = { + id, title, slug, status, created_at, updated_at, + tags, categories + } +} + +invariant ConditionalPostFields { + -- These fields are ONLY written if truthy + for p in Posts: + conditional_fields(p) = { + excerpt, author, language, template_slug, published_at + } + -- do_not_translate is only written when true +} + +invariant RequiredMediaFields { + -- These fields are ALWAYS written for media sidecars + -- Note: 'filename' is NOT a sidecar field — it is the binary path itself + for m in Media: + required_fields(m) = { + id, original_name, mime_type, size, + created_at, updated_at, tags + } +} + +invariant ConditionalMediaFields { + -- These fields are ONLY written if truthy + for m in Media: + conditional_fields(m) = { + title, alt, caption, author, language, width, height + } +} diff --git a/specs/generation.allium b/specs/generation.allium new file mode 100644 index 0000000..781a872 --- /dev/null +++ b/specs/generation.allium @@ -0,0 +1,231 @@ +-- allium: 1 +-- bDS Static Site Generation +-- Scope: core (Wave 4) +-- Distilled from: src/main/engine/BlogGenerationEngine.ts, +-- PageRenderer.ts, GenerationWorkerPool, RoutePageGenerationService + +use "./post.allium" as post +use "./template.allium" as template +use "./metadata.allium" as meta +use "./menu.allium" as menu +use "./translation.allium" as translation + +surface GenerationControlSurface { + facing _: GenerationOperator + + provides: + GenerateSiteRequested(generation) + ValidateSiteRequested(project) + ApplyValidationRequested(project_id, sections) +} + +surface GenerationRuntimeSurface { + facing _: GenerationRuntime + + provides: + PageRenderRequested(template, context) + GenerateSiteCompleted(generation) +} + +value GenerationSection { + kind: core | single | category | tag | date +} + +value GeneratedFile { + relative_path: String + content_hash: String +} + +entity SiteGeneration { + project_id: String + base_url: String + language: String -- main language + blog_languages: Set + max_posts_per_page: Integer + pico_theme: String? + sections: Set + + -- Output tracking + generated_files: GeneratedFile with project_id = this.project_id +} + +surface GenerationStatusSurface { + context generation: SiteGeneration + + exposes: + generation.project_id + generation.base_url + generation.language + generation.blog_languages + generation.max_posts_per_page + generation.pico_theme + generation.sections + generation.generated_files.count +} + +invariant IncrementalByContentHash { + -- Files are only written when content_hash changes + -- generatedFileHashes table tracks (projectId, relativePath, contentHash) + -- A file with unchanged hash is skipped on regeneration +} + +invariant MultiLanguageRoutes { + -- Main language: flat routes (/{yyyy}/{mm}/{dd}/{slug}) + -- Additional languages: prefixed (/{lang}/{yyyy}/{mm}/{dd}/{slug}) + -- Each language subtree gets its own feeds and archives +} + +invariant CanonicalBaseUrlConfigured { + for generation in SiteGenerations: + generation.base_url != "" +} + +invariant NamedPicoTheme { + for generation in SiteGenerations where generation.pico_theme != null: + generation.pico_theme != "" +} + +invariant GeneratedFilesTracked { + for generation in SiteGenerations: + generation.generated_files.count >= 0 +} + +-- Core section: root pages, sitemap, RSS, Atom, calendar.json + +rule GenerateCoreSectionPages { + when: GenerateSiteRequested(generation) + requires: core in generation.sections + ensures: FileGenerated("index.html") + ensures: FileGenerated("sitemap.xml") + -- Multi-language sitemap with hreflang alternates + ensures: FileGenerated("feed.xml") + -- RSS 2.0 feed + ensures: FileGenerated("atom.xml") + -- Atom feed + ensures: FileGenerated("calendar.json") + -- Post dates for calendar widget + 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)) +} + +-- Single section: one HTML page per published post + +rule GenerateSinglePostPages { + when: GenerateSiteRequested(generation) + requires: single in generation.sections + for p in Posts where status = published: + let url = post_canonical_url(p) + ensures: FileGenerated(format("{url}/index.html", url: url)) + for lang in generation.blog_languages - {generation.language}: + if p.translations.any(t => t.language.code = lang): + ensures: FileGenerated(format("{lang}/{url}/index.html", + lang: lang, url: url)) +} + +-- Category section: paginated archive per category + +rule GenerateCategoryPages { + when: GenerateSiteRequested(generation) + requires: category in generation.sections + for cat in generation.categories: + let page_count = ceil(posts_in_category(cat).count / generation.max_posts_per_page) + ensures: FileGenerated(format("category/{cat}/index.html", cat: cat)) + for page in page_range(2, page_count): + ensures: FileGenerated(format("category/{cat}/page/{page}/index.html", + cat: cat, page: page)) +} + +-- Tag section: paginated archive per tag + +rule GenerateTagPages { + when: GenerateSiteRequested(generation) + requires: tag in generation.sections + for t in Tags where post_count > 0: + ensures: FileGenerated(format("tag/{slug}/index.html", slug: slugify(t.name))) +} + +-- Date section: year and month archives + +rule GenerateDateArchivePages { + when: GenerateSiteRequested(generation) + requires: date in generation.sections + for year in distinct_years(Posts): + ensures: FileGenerated(format("{year}/index.html", year: year)) + for month in distinct_months(Posts, year): + ensures: FileGenerated(format("{year}/{month}/index.html", + year: year, month: month)) +} + +-- Template rendering context + +rule RenderPage { + when: PageRenderRequested(template, context) + -- Template rendering with full context: + -- posts, pagination, menus, tags, categories, + -- project metadata, i18n translations, theme settings + -- Macro expansion: [[slug param1=value1 ...]] in post content + -- HTML rewriting for canonical post/media paths + ensures: RenderedHtml(template, context, output) +} + +-- Validation + +rule ValidateSite { + when: ValidateSiteRequested(project) + -- Compares sitemap URLs to HTML files on disk + -- Detects: missing pages, extra (stale) pages, sitemap/file mismatches + ensures: ValidationReport(missing_pages, extra_pages, stale_pages) +} + +rule ApplyValidation { + when: ApplyValidationRequested(project_id, sections) + -- Targeted re-rendering for affected sections only + ensures: GenerateSiteRequested(plan_generation(project_id, sections)) +} + +-- Day-block grouping for archives +invariant ArchiveDayBlocks { + -- Archive/list pages group posts by day + -- Each day block has a date header and the posts for that day +} + +-- ============================================================================ +-- SEARCH INDEX: PAGEFIND +-- ============================================================================ + +-- Pagefind builds a client-side full-text search index from generated HTML. +-- Uses an embedded Pagefind library integration rather than a CLI subprocess. +-- Runs as the final step of the generation pipeline, after all HTML is written. + +rule BuildSearchIndex { + when: GenerateSiteCompleted(generation) + -- Only runs if any pages were rendered or deleted in this generation pass. + -- Separate index per language: + -- Main language: source = {html}/, output = {html}/pagefind/ + -- Each additional language: source = {html}/{lang}/, output = {html}/{lang}/pagefind/ + -- Each index built with force_language set to that language code. + for lang in {generation.language} + (generation.blog_languages - {generation.language}): + ensures: PagefindIndexBuilt(lang) +} + +invariant PagefindHtmlMarking { + -- Single-post templates must include data-pagefind-body attribute + -- on the
element to scope indexing to post content only. + -- Pagefind ignores elements without this attribute. +} + +invariant PagefindAssets { + -- Generated output includes Pagefind UI assets per language: + -- {prefix}/pagefind/pagefind-ui.css + -- {prefix}/pagefind/pagefind-ui.js + -- where prefix is "" for main language, "{lang}" for additional languages. + -- Frontend templates reference these via language_prefix variable. + -- Assets are bundled locally — no external CDN references. +} + +config { + pagefind_threshold: Integer = 0 + -- Minimum pages to trigger indexing (0 = always if any rendered/deleted) +} diff --git a/specs/git.allium b/specs/git.allium new file mode 100644 index 0000000..eab9fdb --- /dev/null +++ b/specs/git.allium @@ -0,0 +1,166 @@ +-- allium: 1 +-- bDS Git Integration +-- Scope: extension (Bucket A — Git + Validation) +-- Distilled from: src/main/engine/GitEngine.ts + +use "./post.allium" as post +use "./script.allium" as script +use "./template.allium" as template + +value GitProvider { + kind: github | gitlab | gitea_forgejo + -- Detected from remote URL patterns +} + +value GitSyncStatus { + -- Per-commit: local_only | remote_only | both + kind: local_only | remote_only | both +} + +surface GitSyncStatusSurface { + context status: GitSyncStatus + + exposes: + status.kind +} + +entity GitRepository { + is_initialized: Boolean + remote_url: String? + provider: GitProvider? + current_branch: String? + has_lfs: Boolean +} + +surface GitRepositorySurface { + context repo: GitRepository + + exposes: + repo.is_initialized + repo.remote_url when repo.remote_url != null + repo.provider when repo.provider != null + repo.current_branch when repo.current_branch != null + repo.has_lfs +} + +surface GitControlSurface { + facing _: GitOperator + + provides: + InitializeRepoRequested(project) + GitStatusRequested(project) + GitDiffRequested(project) + GitHistoryRequested(project, branch) + GitFetchRequested(project) + GitPullRequested(project) + GitPushRequested(project) + GitCommitAllRequested(project, message) + GitReconcileRequested(project, old_commit, new_commit) + PruneLfsCacheRequested(project, retain_recent) +} + +rule InitializeRepo { + when: InitializeRepoRequested(project) + ensures: GitRepository.created( + is_initialized: true, + remote_url: null, + provider: null, + current_branch: "master", + has_lfs: true + ) + ensures: GitignoreCreated(project) + -- .gitignore manages generated artifacts, cached assets, dependency directories, etc. + ensures: LfsTrackingConfigured(project) + -- Git LFS auto-tracks image patterns (*.jpg, *.png, *.gif, etc.) +} + +rule GetStatus { + when: GitStatusRequested(project) + -- Returns file-level status: added, modified, deleted, renamed, untracked + ensures: GitStatusReport(files) +} + +rule GetDiff { + when: GitDiffRequested(project) + ensures: GitDiffReport(staged_diff, unstaged_diff) +} + +rule GetHistory { + when: GitHistoryRequested(project, branch) + -- Returns commit history with sync status per commit + ensures: GitHistoryReport(commits) + @guidance + -- Each commit annotated with: local_only, remote_only, or both + -- This drives the "push needed" / "pull needed" indicators +} + +rule Fetch { + when: GitFetchRequested(project) + ensures: RemoteRefsUpdated(project) +} + +rule Pull { + when: GitPullRequested(project) + ensures: LocalBranchUpdated(project) + ensures: GitReconcileRequested(project, previous_head(project), current_head(project)) + -- After pull, detect changed files and reconcile DB +} + +rule Push { + when: GitPushRequested(project) + ensures: RemoteBranchUpdated(project) +} + +rule CommitAll { + when: GitCommitAllRequested(project, message) + ensures: AllChangesStaged(project) + ensures: CommitCreated(project, message) +} + +-- Git reconciliation: sync DB from filesystem changes + +rule ReconcileFromGit { + when: GitReconcileRequested(project, old_commit, new_commit) + -- Detect changed files between commits for posts, scripts, templates + let post_changes = changed_post_files(old_commit, new_commit) + let script_changes = changed_script_files(old_commit, new_commit) + let template_changes = changed_template_files(old_commit, new_commit) + + for added in post_changes.added: + ensures: post/Post.created(parse_post_file(added)) + for modified in post_changes.modified: + ensures: PostUpdatedFromFile(modified) + for deleted in post_changes.deleted: + ensures: PostDeletedByPath(deleted) + for renamed in post_changes.renamed: + ensures: PostFileRenamed(renamed.old, renamed.new) + + -- Same pattern for scripts and templates + for added in script_changes.added: + ensures: script/Script.created(parse_script_file(added)) + for added in template_changes.added: + ensures: template/Template.created(parse_template_file(added)) + + ensures: EntityChangedEventsEmitted(project) +} + +invariant NonInteractiveGit { + -- All git operations run non-interactively: + -- GIT_TERMINAL_PROMPT=0 + -- GCM_INTERACTIVE=never + -- ssh -oBatchMode=yes + -- No password prompts ever surface to the user +} + +invariant StructuredAuthErrors { + -- Auth failures produce structured guidance: + -- per platform (macOS/Windows/Linux) + -- per provider (GitHub/GitLab/Gitea) + -- Instead of raw git error messages +} + +rule PruneLfsCache { + when: PruneLfsCacheRequested(project, retain_recent) + -- Prunes LFS cache with configurable recent commit retention + ensures: LfsCachePruned(project) +} diff --git a/specs/i18n.allium b/specs/i18n.allium new file mode 100644 index 0000000..bca240e --- /dev/null +++ b/specs/i18n.allium @@ -0,0 +1,54 @@ +-- allium: 1 +-- bDS Internationalization +-- Scope: core (Wave 0 onward — split localization is mandatory) +-- Distilled from: src/main/shared/i18n.ts, i18n/locales/*.json + +value SupportedLanguage { + code: String + -- en, de, fr, it, es + flag: String + -- en=GB, de=DE, fr=FR, it=IT, es=ES +} + +config { + supported_languages: Set = { + SupportedLanguage(code: "en", flag: "GB"), + SupportedLanguage(code: "de", flag: "DE"), + SupportedLanguage(code: "fr", flag: "FR"), + SupportedLanguage(code: "it", flag: "IT"), + SupportedLanguage(code: "es", flag: "ES") + } + default_language: String = "en" +} + +invariant SplitLocalization { + -- Two independent locale scopes: + -- 1. UI locale: follows OS system locale + -- 2. Content/render locale: follows project settings (mainLanguage) + -- These are resolved independently and may differ +} + +invariant LanguageNormalization { + -- Input language codes are normalized: + -- Take base language code (split on '-'): "en-US" -> "en" + -- Fall back to "en" if unrecognized +} + +invariant MenuTranslations { + -- Menu item labels are separately translatable + -- translateMenu() uses UI locale (system/OS locale), NOT render locale + -- This follows the OS convention: menus match the system language +} + +invariant RenderTranslations { + -- Template rendering i18n strings (date formats, archive labels, + -- "older posts", "newer posts", etc.) come from locale JSON files + -- translateRender() and getRenderTranslations() provide these +} + +-- Stemmer language support for search (broader than UI languages) +invariant SnowballStemmerCoverage { + -- 24 languages supported for FTS5 search stemming + -- ISO 639-1 mapped to Snowball stemmer names + -- All 5 UI languages are a subset of stemmer languages +} diff --git a/specs/layout.allium b/specs/layout.allium new file mode 100644 index 0000000..68fe061 --- /dev/null +++ b/specs/layout.allium @@ -0,0 +1,291 @@ +-- allium: 1 +-- bDS Application Layout +-- Scope: UI shell (all waves) +-- Distilled from: src/renderer/App.tsx, ActivityBar.tsx, StatusBar.tsx, +-- Panel.tsx, WindowTitleBar.tsx, ResizablePanel, Sidebar.tsx + +-- The top-level visual structure of the application window. +-- Describes the shell (regions, toggle behaviour, resize constraints) +-- but NOT the content of each region (see tabs.allium, sidebar_views.allium). + +use "./i18n.allium" as i18n +use "./task.allium" as task + +surface LayoutControlSurface { + facing _: LayoutOperator + + provides: + ToggleSidebarRequested() + TogglePanelRequested() + ToggleAssistantSidebarRequested() + ActivityClicked(activity_id) +} + +surface LayoutRuntimeSurface { + facing _: LayoutRuntime + + provides: + GitBadgePollTick(badge) + ClearGitBadgeTick(badge) +} + +-- ─── Window shell ───────────────────────────────────────────── + +-- +------------------------------------------------------------+ +-- | WindowTitleBar | +-- +----+--------+----------------------------+-----------------+ +-- | A | Side- | TabBar | Assistant | +-- | c | bar |----------------------------| Sidebar | +-- | t | (resz) | Editor (routed by tab) | | +-- | i | |----------------------------+ | +-- | v | | Panel (bottom) | | +-- | i | | | | +-- | t | | | | +-- | y | | | | +-- +----+--------+----------------------------+-----------------+ +-- | StatusBar | +-- +------------------------------------------------------------+ + +value AppShell { + title_bar: WindowTitleBar + activity_bar: ActivityBar + sidebar: ResizableRegion + content_area: ContentArea + assistant_sidebar: ResizableRegion + status_bar: StatusBar +} + +surface AppShellSurface { + context shell: AppShell + + exposes: + shell.title_bar.title + shell.sidebar.visible + shell.sidebar.width + shell.content_area.panel.visible + shell.assistant_sidebar.visible +} + +value ContentArea { + -- tab_bar: see tabs.allium + -- editor: routed by active tab; see tabs.allium + panel: Panel +} + +-- ─── Resizable regions ──────────────────────────────────────── + +config { + sidebar_initial_width: Integer = 280 + sidebar_min_width: Integer = 200 + sidebar_max_width: Integer = 500 + assistant_initial_width: Integer = 360 + assistant_min_width: Integer = 280 + assistant_max_width: Integer = 640 +} + +value ResizableRegion { + visible: Boolean + width: Integer + min_width: Integer + max_width: Integer +} + +-- ─── Toggle state ───────────────────────────────────────────── + +value ShellVisibility { + sidebar_visible: Boolean + panel_visible: Boolean + assistant_sidebar_visible: Boolean +} + +surface ShellVisibilitySurface { + context visibility: ShellVisibility + + exposes: + visibility.sidebar_visible + visibility.panel_visible + visibility.assistant_sidebar_visible +} + +rule ToggleSidebar { + when: ToggleSidebarRequested() + ensures: sidebar_visible = not sidebar_visible +} + +rule TogglePanel { + when: TogglePanelRequested() + ensures: panel_visible = not panel_visible +} + +rule ToggleAssistantSidebar { + when: ToggleAssistantSidebarRequested() + ensures: assistant_sidebar_visible = not assistant_sidebar_visible +} + +-- ─── Window title bar ───────────────────────────────────────── + +value WindowTitleBar { + -- Platform-adaptive + -- menu_bar: rendered only on non-Mac platforms (6 groups: App, File, Edit, View, Window, Help) + -- macOS: native menu bar (same 6 groups) + -- Keyboard: Alt opens mnemonics, Alt+letter opens group + -- Arrow keys navigate groups and items, Enter/Space activates + -- View group hides devTools toggle when not in dev mode + title: String -- document.title, fallback "Blogging Desktop Server" + -- Three toggle buttons (all platforms): sidebar, panel, assistant +} + +-- ─── Activity bar ───────────────────────────────────────────── + +-- Narrow vertical icon strip at the far-left edge of the window. +-- Two groups: top (content views) and bottom (tools). + +value ActivityBar { + top_group: List + bottom_group: List +} + +value ActivityButton { + id: String -- matches SidebarView name + label_key: String -- i18n key for tooltip + badge: Badge? -- only git has a badge + active: Boolean -- highlighted when this view is showing +} + +value Badge { + count: Integer + display: String -- count capped at "99+" +} + +-- Exhaustive activity list with preserved order +-- Top group (content views): +-- 1. posts i18n:activity.posts +-- 2. pages i18n:activity.pages +-- 3. media i18n:activity.media +-- 4. scripts i18n:activity.scripts +-- 5. templates i18n:activity.templates +-- 6. tags i18n:activity.tags +-- 7. chat i18n:activity.aiAssistant +-- 8. import i18n:activity.import +-- Bottom group (tools): +-- 9. git i18n:activity.sourceControl (badge: pending pull count) +-- 10. settings i18n:common.settings + +-- Each activity ID maps 1:1 to a SidebarView of the same name. + +-- ─── Activity click behaviour ───────────────────────────────── + +-- All activities share the same toggle-sidebar strategy. +-- The sidebar shows the view that matches the clicked activity. + +rule ActivityClick { + when: ActivityClicked(activity_id) + let target_view = activity_id + if active_view = target_view: + ensures: ToggleSidebarRequested() + -- If already on this view, toggle sidebar open/closed + else: + ensures: active_view = target_view + if not sidebar_visible: + ensures: ToggleSidebarRequested() + -- Switch view; open sidebar if hidden +} + +invariant ActivityActiveHighlight { + -- An activity button shows active state iff its view is the + -- current active_view AND the sidebar is visible + for btn in ActivityBar.all_buttons: + btn.active = (active_view = btn.id and sidebar_visible) +} + +-- ─── Git badge ──────────────────────────────────────────────── + +-- Only the git activity button carries a badge. +-- Badge shows remote "behind" count, polled every 30 seconds. + +config { + git_badge_poll_interval: Integer = 30 + -- seconds between badge refresh polls +} + +rule RefreshGitBadge { + when: GitBadgePollTick(badge) + requires: online and active_project != null + let repo_state = git.getRepoState() + requires: repo_state.is_repo and repo_state.has_remote + ensures: git.fetch() + let remote_state = git.getRemoteState() + ensures: badge.count = max(0, remote_state.behind) +} + +rule ClearGitBadge { + when: ClearGitBadgeTick(badge) + requires: not online or active_project = null or not is_repo or not has_remote + ensures: badge.count = 0 +} + +-- ─── Bottom panel ───────────────────────────────────────────── + +value Panel { + visible: Boolean + active_tab: String -- tasks | output | post_links | git_log +} + +-- Panel tab availability depends on active editor tab +invariant PanelTabAvailability { + -- tasks: always available + -- output: always available + -- post_links: only when active editor tab is a post + -- git_log: only when active editor tab is a post or media +} + +invariant PanelTabFallback { + -- If active panel tab becomes unavailable, fall back to tasks + -- post_links unavailable when no post tab is active + -- git_log unavailable when neither post nor media tab is active +} + +-- Tasks tab: last 10 tasks, newest first, with progress/cancel. +-- Tasks with shared group_id are collapsible groups showing aggregate progress. +-- Output tab: log entries with copy-all button. +-- Post Links tab: backlinks (posts linking here) + outlinks (posts linked from here). +-- Each entry clickable, opens linked post as pinned tab. +-- Git Log tab: file-level git history for active post/media (up to 50 entries). +-- For posts: path = posts/YYYY/MM/{slug}.md +-- For media: path relative to project root + +-- ─── Status bar ─────────────────────────────────────────────── + +value StatusBar { + left: StatusBarLeft + right: StatusBarRight +} + +value StatusBarLeft { + -- Project selector dropdown to switch active project + running_task_message: String? -- spinner + message when tasks running + running_task_overflow: Integer? -- "+N more" count when multiple running +} + +value StatusBarRight { + -- In display order (left to right): + post_status: String? -- draft|published|archived dot, when post tab active + post_count: String -- "{count} posts" + media_count: String -- "{count} media" + token_usage: TokenUsage? -- shown only when active tab is chat + theme_badge: String -- pico theme name + offline_mode: Boolean -- airplane icon toggle, keyboard accessible + ui_language: String -- dropdown: en, de, fr, it, es + brand: String -- "bDS" +} + +value TokenUsage { + input_tokens: Integer + output_tokens: Integer + cache_read_tokens: Integer +} + +-- ─── Keyboard shortcuts (global) ────────────────────────────── + +-- Ctrl/Cmd+B: toggle sidebar +-- Ctrl/Cmd+W: close active tab (see tabs.allium) diff --git a/specs/mcp.allium b/specs/mcp.allium new file mode 100644 index 0000000..2a91932 --- /dev/null +++ b/specs/mcp.allium @@ -0,0 +1,392 @@ +-- allium: 1 +-- bDS MCP Server (Model Context Protocol) +-- Scope: extension (Bucket G — MCP + Automation) +-- Distilled from: src/main/engine/MCPServer.ts, ProposalStore, MCPAgentConfigEngine.ts + +use "./post.allium" as post +use "./media.allium" as media +use "./script.allium" as script +use "./template.allium" as template + +entity McpServer { + transport: http | stdio + host: String -- 127.0.0.1 for HTTP + port: Integer -- 4124 for HTTP + is_running: Boolean +} + +surface McpServerSurface { + context server: McpServer + + exposes: + server.transport + server.host + server.port + server.is_running +} + +entity Proposal { + kind: draft_post | propose_script | propose_template | propose_media_metadata | propose_post_metadata + status: pending | accepted | discarded | expired + entity_id: String + data: String + created_at: Timestamp + expires_at: Timestamp + draft_post: post/Post? + proposed_script: script/Script? + proposed_template: template/Template? + target_media: media/Media? + target_post: post/Post? + + -- Derived + is_expired: expires_at <= now + + transitions status { + pending -> accepted + pending -> discarded + pending -> expired + } +} + +surface ProposalSurface { + context proposal: Proposal + + exposes: + proposal.kind + proposal.status + proposal.entity_id + proposal.data + proposal.created_at + proposal.expires_at + proposal.draft_post when proposal.draft_post != null + proposal.proposed_script when proposal.proposed_script != null + proposal.proposed_template when proposal.proposed_template != null + proposal.target_media when proposal.target_media != null + proposal.target_post when proposal.target_post != null + proposal.is_expired +} + +config { + http_port: Integer = 4124 + proposal_ttl_app: Duration = 30.minutes + proposal_ttl_cli: Duration = 8.hours +} + +surface McpAutomationSurface { + facing _: McpClient + + provides: + McpToolInvoked("check_term", term) + McpToolInvoked("search_posts", params) + McpToolInvoked("count_posts", params) + McpToolInvoked("read_post_by_slug", slug, language) + McpToolInvoked("draft_post", params) + McpToolInvoked("propose_script", params) + McpToolInvoked("propose_template", params) + McpToolInvoked("propose_media_metadata", params) + McpToolInvoked("propose_post_metadata", params) + AcceptProposalRequested(proposal) + DiscardProposalRequested(proposal) + InstallAgentConfigRequested(agent_kind) + UninstallAgentConfigRequested(agent_kind) +} + +invariant LocalhostOnlyHttp { + -- HTTP transport binds to 127.0.0.1 only + -- Origin validation: localhost only + -- CORS headers present +} + +invariant StatelessHttpHandling { + -- Each HTTP request creates a fresh McpServer instance + -- No session state between requests +} + +-- Read-only resources (bds:// scheme) + +surface PostsResource { + facing viewer: McpClient + context posts: Posts + exposes: + for p in posts: + p.id + p.title + p.slug + p.status + p.tags + p.categories + p.created_at + p.backlinks + p.outlinks + @guidance + -- Paginated: 50 per page, base64url cursor + -- bds://posts, bds://posts?cursor={cursor} +} + +surface MediaResource { + facing viewer: McpClient + context media_items: Media + exposes: + for m in media_items: + m.id + m.filename + m.title + m.alt + m.caption + m.tags + @guidance + -- bds://media, bds://media?cursor={cursor} +} + +surface TagsResource { + facing viewer: McpClient + context tags: Tags + exposes: + for t in tags: + t.name + t.color + t.post_count + @guidance + -- bds://tags +} + +surface CategoriesResource { + facing viewer: McpClient + context categories: Categories + exposes: + for c in categories: + c.name + c.post_count + @guidance + -- bds://categories +} + +-- Read-only tools + +rule CheckTerm { + when: McpToolInvoked("check_term", term) + -- Disambiguates a term as category, tag, or both + -- Returns post counts for each + let is_category = is_category_term(term) + let is_tag = is_tag_term(term) + ensures: TermCheckResult( + is_category: is_category, + category_post_count: if is_category: category_post_count(term) else: 0, + is_tag: is_tag, + tag_post_count: if is_tag: tag_post_count(term) else: 0 + ) +} + +rule SearchPosts { + when: McpToolInvoked("search_posts", params) + -- Full-text + filtered search with pagination envelope + -- Params: query, category, tags[], language, missingTranslationLanguage, + -- year, month, status, offset, limit + -- Returns: { total, offset, limit, hasMore, posts[] } + -- Each post includes backlinks[] and linksTo[] + ensures: SearchEnvelope(results) +} + +rule CountPosts { + when: McpToolInvoked("count_posts", params) + -- Grouped counts by: year, month, tag, category, status + -- Params: groupBy[], optional filters + ensures: GroupedCounts(results) +} + +rule ReadPostBySlug { + when: McpToolInvoked("read_post_by_slug", slug, language) + -- Full post content by slug + -- Optional language parameter for translation view + ensures: FullPostContent(post) +} + +-- Write tools (proposal-based) + +rule DraftPost { + when: McpToolInvoked("draft_post", params) + -- Creates a draft post in DB + -- Returns proposalId for accept/discard lifecycle + ensures: + let new_post = post/Post.created( + title: params.title, + content: params.content, + status: draft + ) + let proposal = Proposal.created( + kind: draft_post, + entity_id: new_post.id, + data: "", + created_at: now, + expires_at: now + config.proposal_ttl_app, + draft_post: new_post, + proposed_script: null, + proposed_template: null, + target_media: null, + target_post: null, + status: pending + ) + proposal.status = pending +} + +rule ProposeScript { + when: McpToolInvoked("propose_script", params) + requires: ValidateScript(params.content) = valid + ensures: + let new_script = script/Script.created( + title: params.title, + kind: params.kind, + content: params.content, + status: draft + ) + let proposal = Proposal.created( + kind: propose_script, + entity_id: new_script.id, + data: "", + created_at: now, + expires_at: now + config.proposal_ttl_app, + draft_post: null, + proposed_script: new_script, + proposed_template: null, + target_media: null, + target_post: null, + status: pending + ) + proposal.status = pending +} + +rule ProposeTemplate { + when: McpToolInvoked("propose_template", params) + requires: ValidateLiquid(params.content) = valid + ensures: + let new_template = template/Template.created( + title: params.title, + kind: params.kind, + content: params.content, + status: draft + ) + let proposal = Proposal.created( + kind: propose_template, + entity_id: new_template.id, + data: "", + created_at: now, + expires_at: now + config.proposal_ttl_app, + draft_post: null, + proposed_script: null, + proposed_template: new_template, + target_media: null, + target_post: null, + status: pending + ) + proposal.status = pending +} + +rule ProposeMediaMetadata { + when: McpToolInvoked("propose_media_metadata", params) + ensures: + let proposal = Proposal.created( + kind: propose_media_metadata, + entity_id: params.media_id, + data: serialize(params), + created_at: now, + expires_at: now + config.proposal_ttl_app, + draft_post: null, + proposed_script: null, + proposed_template: null, + target_media: params.media, + target_post: null, + status: pending + ) + proposal.status = pending +} + +rule ProposePostMetadata { + when: McpToolInvoked("propose_post_metadata", params) + ensures: + let proposal = Proposal.created( + kind: propose_post_metadata, + entity_id: params.post_id, + data: serialize(params), + created_at: now, + expires_at: now + config.proposal_ttl_app, + draft_post: null, + proposed_script: null, + proposed_template: null, + target_media: null, + target_post: params.post, + status: pending + ) + proposal.status = pending +} + +-- Proposal lifecycle + +rule AcceptProposal { + when: AcceptProposalRequested(proposal) + requires: not proposal.is_expired + ensures: + if proposal.kind = draft_post: + post/PublishPostRequested(proposal.draft_post) + if proposal.kind = propose_script: + script/PublishScriptRequested(proposal.proposed_script) + if proposal.kind = propose_template: + template/PublishTemplateRequested(proposal.proposed_template) + if proposal.kind = propose_media_metadata: + media/UpdateMediaRequested(proposal.target_media, deserialize_media_changes(proposal.data)) + if proposal.kind = propose_post_metadata: + post/UpdatePostRequested(proposal.target_post, deserialize_post_changes(proposal.data)) + proposal.status = accepted + not exists proposal +} + +rule DiscardProposal { + when: DiscardProposalRequested(proposal) + ensures: + if proposal.kind = draft_post: + post/DeletePostRequested(proposal.draft_post) + if proposal.kind = propose_script: + script/DeleteScriptRequested(proposal.proposed_script) + if proposal.kind = propose_template: + template/DeleteTemplateRequested(proposal.proposed_template) + proposal.status = discarded + not exists proposal +} + +rule ExpireProposal { + when: proposal: Proposal.is_expired becomes true + -- On expiry: clean up draft DB rows + ensures: proposal.status = expired + ensures: DiscardProposalRequested(proposal) +} + +-- Agent configuration + +value McpAgentKind { + -- Supported: claude_code, claude_desktop, github_copilot, + -- gemini_cli, opencode, mistral_vibe, openai_codex + kind: String +} + +surface McpAgentKindSurface { + context agent_kind: McpAgentKind + + exposes: + agent_kind.kind +} + +rule InstallAgentConfig { + when: InstallAgentConfigRequested(agent_kind) + -- Writes stdio MCP server config into the agent's config file + ensures: AgentConfigInstalled(agent_kind) +} + +rule UninstallAgentConfig { + when: UninstallAgentConfigRequested(agent_kind) + ensures: AgentConfigRemoved(agent_kind) +} + +invariant ProposalPayloadEncoding { + -- Proposal.data stores a serialized payload for metadata proposals. + -- draft_post / propose_script / propose_template proposals keep the + -- created entity reference directly on the proposal record. +} diff --git a/specs/media.allium b/specs/media.allium new file mode 100644 index 0000000..4389732 --- /dev/null +++ b/specs/media.allium @@ -0,0 +1,198 @@ +-- allium: 1 +-- bDS Media Lifecycle +-- Scope: core (Wave 1) +-- Distilled from: src/main/engine/MediaEngine.ts, schema.ts + +use "./project.allium" as project + +surface MediaControlSurface { + facing _: MediaOperator + + provides: + ImportMediaRequested(project, source_file) + UpdateMediaRequested(media, changes) + DeleteMediaRequested(media) + UpsertMediaTranslationRequested(media, language, title, alt, caption) + RebuildMediaFromFilesRequested(project) +} + +value ThumbnailSet { + small: String -- 150px width (binary path) + medium: String -- 400px width (binary path) + large: String -- 800px width (binary path) + ai: String -- 448x448 JPEG for vision models (binary path) +} + +value SidecarFile { + -- {media_file}.meta (YAML-like key-value format) + -- Fields: title, alt, caption, author, tags, language, linkedPostIds + -- Translations: {media_file}.{lang}.meta + path: String +} + +surface SidecarFileSurface { + context sidecar: SidecarFile + + exposes: + sidecar.path +} + +entity Media { + project: project/Project + filename: String + original_name: String + mime_type: String + size: Integer + width: Integer? + height: Integer? + title: String? + alt: String? + caption: String? + author: String? + language: String? + file_path: String + sidecar_path: String + checksum: String? + tags: List + created_at: Timestamp + updated_at: Timestamp + + -- Relationships + translations: MediaTranslation with media = this + linked_posts: PostMediaLink with media_id = this.id + + -- Derived + available_languages: translations -> language + thumbnails: ThumbnailSet +} + +surface MediaSurface { + context media: Media + + exposes: + media.project + media.filename + media.original_name + media.mime_type + media.size + media.width when media.width != null + media.height when media.height != null + media.title when media.title != null + media.alt when media.alt != null + media.caption when media.caption != null + media.author when media.author != null + media.language when media.language != null + media.file_path + media.sidecar_path + media.checksum when media.checksum != null + media.tags + media.created_at + media.updated_at + media.translations.count + media.linked_posts.count + media.available_languages + media.thumbnails.small + media.thumbnails.medium + media.thumbnails.large + media.thumbnails.ai +} + +entity MediaTranslation { + media: Media + language: String + title: String? + alt: String? + caption: String? +} + +invariant UniqueMediaTranslation { + for a in MediaTranslations: + for b in MediaTranslations: + (a != b and a.media = b.media) implies a.language != b.language +} + +invariant DateBasedMediaLayout { + for m in Media: + m.file_path = format("media/{yyyy}/{mm}/{uuid}.{ext}", + yyyy: m.created_at.year, + mm: m.created_at.month_padded, + uuid: stem(m.filename), + ext: extension(m.filename)) +} + +rule ImportMedia { + when: ImportMediaRequested(project, source_file) + let uuid_name = generate_uuid() + extension(source_file) + let dest = format("media/{yyyy}/{mm}/{uuid_name}", + yyyy: now.year, mm: now.month_padded) + ensures: Media.created( + project: project, + filename: uuid_name, + original_name: source_file.name, + mime_type: detect_mime(source_file), + size: source_file.size, + width: detect_width(source_file), + height: detect_height(source_file), + file_path: dest, + tags: {} + ) + ensures: FileCopied(source_file, dest) + ensures: SidecarWritten(media) + ensures: ThumbnailsGenerated(media) + ensures: SearchIndexUpdated(media) +} + +rule UpdateMedia { + when: UpdateMediaRequested(media, changes) + ensures: MediaFieldsUpdated(media, changes) + ensures: media.updated_at = now + ensures: SidecarWritten(media) + -- Metadata changes flush to .meta sidecar + ensures: SearchIndexUpdated(media) +} + +rule DeleteMedia { + when: DeleteMediaRequested(media) + ensures: not exists media + ensures: MediaFileDeleted(media) + ensures: SidecarDeleted(media) + ensures: ThumbnailsDeleted(media) + ensures: + for t in media.translations: + not exists t + ensures: SearchIndexUpdated(media) +} + +rule UpsertMediaTranslation { + when: UpsertMediaTranslationRequested(media, language, title, alt, caption) + ensures: MediaTranslation.created( + media: media, + language: language, + title: title, + alt: alt, + caption: caption + ) + ensures: TranslationSidecarWritten(media, language) + -- Writes {file}.{lang}.meta +} + +rule RebuildMediaFromFiles { + when: RebuildMediaFromFilesRequested(project) + -- Scans media directory for .meta sidecars, reimports to DB + for sidecar in scan_directory(project.effective_data_dir + "/media", "*.meta"): + let parsed = parse_sidecar(sidecar) + ensures: Media.created(parsed) + -- or updated if already exists + @guidance + -- This is the filesystem-to-DB reconciliation path + -- Used after git pull or manual file changes +} + +invariant SidecarRoundtrip { + -- Sidecar files faithfully represent DB metadata + for m in Media: + parse_sidecar(m.sidecar_path).title = m.title + parse_sidecar(m.sidecar_path).alt = m.alt + parse_sidecar(m.sidecar_path).caption = m.caption + parse_sidecar(m.sidecar_path).tags = m.tags +} diff --git a/specs/media_processing.allium b/specs/media_processing.allium new file mode 100644 index 0000000..0df5679 --- /dev/null +++ b/specs/media_processing.allium @@ -0,0 +1,374 @@ +-- allium: 1 +-- bDS Media Processing Specification +-- Scope: core (Wave 1 — media import and processing) +-- Distilled from: ../bDS/src/main/engine/MediaEngine.ts, +-- mediaProcessing.ts, thumbnail generation logic +-- +-- This document specifies the exact media processing behavior: +-- thumbnail generation, format conversion, EXIF handling, and file organization. + +use "./media.allium" as media +use "./search.allium" as search + +surface MediaProcessingControlSurface { + facing _: MediaProcessingOperator + + provides: + ImportMediaRequested(source_path, project) + TagMediaRequested(media, tags) + DeleteMediaRequested(media) + ValidateMediaRequested(project) +} + +surface MediaProcessingRuntimeSurface { + facing _: MediaProcessingRuntime + + provides: + MediaImported(media) +} + +-- ============================================================================ +-- MEDIA FILE ORGANIZATION +-- ============================================================================ + +value MediaFileLayout { + -- Binary assets stored in: media/{YYYY}/{MM}/{uuid}.{ext} + -- Sidecar metadata in: {binary_path}.meta + -- Thumbnails in: thumbnails/{id[0:2]}/{id}-{size}.webp + -- (ai thumbnail is JPEG: thumbnails/{id[0:2]}/{id}-ai.jpg) + binary_path: String -- media/{YYYY}/{MM}/{uuid}.{ext} + sidecar_path: String -- {binary_path}.meta + thumbnail_small: String -- thumbnails/{prefix}/{id}-small.webp + thumbnail_medium: String -- thumbnails/{prefix}/{id}-medium.webp + thumbnail_large: String -- thumbnails/{prefix}/{id}-large.webp + thumbnail_ai: String -- thumbnails/{prefix}/{id}-ai.jpg +} + +surface MediaFileLayoutSurface { + context layout: MediaFileLayout + + exposes: + layout.binary_path + layout.sidecar_path + layout.thumbnail_small + layout.thumbnail_medium + layout.thumbnail_large + layout.thumbnail_ai +} + +invariant MediaFileNaming { + -- Original filename is preserved in original_name field + -- Stored filename uses UUID v4: {uuid}.{ext} + -- Example: a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg + for m in Media: + m.filename = format("{uuid}.{ext}", + uuid: generate_uuid_v4(), + ext: file_extension(m.original_name)) +} + +invariant ThumbnailPathBucketing { + -- Thumbnails are bucketed by first 2 chars of media ID + -- This avoids filesystem slowdowns from too many files in one directory + for m in Media: + let prefix = substring(m.id, 0, 2) + m.thumbnails.small = format("thumbnails/{prefix}/{id}-small.webp", + prefix: prefix, id: m.id) + m.thumbnails.medium = format("thumbnails/{prefix}/{id}-medium.webp", + prefix: prefix, id: m.id) + m.thumbnails.large = format("thumbnails/{prefix}/{id}-large.webp", + prefix: prefix, id: m.id) + m.thumbnails.ai = format("thumbnails/{prefix}/{id}-ai.jpg", + prefix: prefix, id: m.id) +} + +-- ============================================================================ +-- THUMBNAIL GENERATION +-- ============================================================================ + +config { + -- Four thumbnail sizes generated per image + thumbnail_small_width: Integer = 150 + thumbnail_medium_width: Integer = 400 + thumbnail_large_width: Integer = 800 + thumbnail_ai_size: Integer = 448 -- 448x448 square crop, JPEG + thumbnail_format: String = "webp" -- All sizes except AI (encoder default quality) + thumbnail_ai_format: String = "jpeg" -- AI thumbnail only +} + +rule GenerateThumbnails { + when: MediaImported(media) + requires: is_image(media.mime_type) + -- Generate all four thumbnail sizes + ensures: ThumbnailGenerated( + source: media.file_path, + destination: media.thumbnails.small, + width: config.thumbnail_small_width, + format: config.thumbnail_format + ) + ensures: ThumbnailGenerated( + source: media.file_path, + destination: media.thumbnails.medium, + width: config.thumbnail_medium_width, + format: config.thumbnail_format + ) + ensures: ThumbnailGenerated( + source: media.file_path, + destination: media.thumbnails.large, + width: config.thumbnail_large_width, + format: config.thumbnail_format + ) + ensures: ThumbnailGenerated( + source: media.file_path, + destination: media.thumbnails.ai, + size: config.thumbnail_ai_size, + format: config.thumbnail_ai_format + ) +} + +-- Thumbnail generation algorithm +value ThumbnailGeneration { + -- 1. Load source image + -- 2. Apply EXIF orientation correction (rotation, flip) so thumbnails display correctly + -- 3. Resize: small/medium/large preserve aspect ratio (width-constrained) + -- AI thumbnail is a 448x448 center crop (letterboxed on black background) + -- 4. Encode as WebP (encoder default quality) for small/medium/large + -- Encode as JPEG for AI thumbnail + -- 5. Write to bucketed thumbnail path: thumbnails/{id[0:2]}/{id}-{size}.{ext} + -- + -- No _source copy is made. Thumbnails regenerated from the original binary. +} + +surface ThumbnailGenerationSurface { + context _: ThumbnailGeneration +} + +invariant ThumbnailExifHandling { + -- EXIF orientation IS applied during thumbnail generation so that + -- thumbnails always appear right-side-up regardless of camera metadata. + -- Width/height stored in DB are the raw header values (pre-rotation). +} + +-- ============================================================================ +-- IMAGE PROCESSING RULES +-- ============================================================================ + +value ImageProcessing { + -- Supported input formats: + input_formats: Set = { + "image/jpeg", "image/png", "image/gif", + "image/webp", "image/tiff", "image/bmp", + "image/heic", "image/heif" + } + + -- Output formats: + output_formats: Set = { + "image/webp", -- Primary output for thumbnails + "image/jpeg" -- AI thumbnail only + } + + -- Processing rules: + -- 1. All thumbnails (except AI) are encoded as WebP (encoder default quality) + -- 2. AI thumbnail is encoded as JPEG (for vision model compatibility) + -- 3. Original format is preserved for full-size assets (no conversion) + -- 4. EXIF data is not stripped (thumbnails are re-encoded, so EXIF is naturally absent) +} + +surface ImageProcessingSurface { + context processing: ImageProcessing + + exposes: + processing.input_formats + processing.output_formats +} + +rule ProcessImageMetadata { + when: MediaImported(media) + -- Extract image metadata from raw file header + ensures: media.width = extract_width_from_header(source_file) + ensures: media.height = extract_height_from_header(source_file) + ensures: media.mime_type = detect_mime_from_extension(source_file) + ensures: media.size = file_size(source_file) +} + +invariant MimeDetection { + -- MIME type is detected from file extension, not from file content/magic bytes + -- Extension mapping: .jpg/.jpeg -> image/jpeg, .png -> image/png, etc. +} + +-- ============================================================================ +-- MEDIA TRANSLATION FILES +-- ============================================================================ + +value MediaTranslationFile { + -- File path: {binary_path}.{language}.meta + -- Format: YAML-like key-value sidecar (same as canonical sidecar) + translation_for: String -- Canonical media ID + language: String -- ISO 639-1 code + title: String? + alt: String? + caption: String? +} + +surface MediaTranslationFileSurface { + context file: MediaTranslationFile + + exposes: + file.translation_for + file.language + file.title when file.title != null + file.alt when file.alt != null + file.caption when file.caption != null +} + +invariant MediaTranslationFileLayout { + for t in MediaTranslations: + -- Translation sidecars sit next to the binary, with language suffix + t.file_path = format("{binary_path}.{lang}.meta", + binary_path: t.media.file_path, + lang: t.language) +} + +-- ============================================================================ +-- MEDIA IMPORT RULES +-- ============================================================================ + +rule ImportMedia { + when: ImportMediaRequested(source_path, project) + -- 1. Validate file type (must be supported image) + -- 2. Generate UUID v4 filename + -- 3. Copy to media/{YYYY}/{MM}/{uuid}.{ext} + -- 4. Write sidecar {binary_path}.meta + -- 5. Generate four thumbnail sizes + -- 6. Index for search (FTS5) + ensures: media/Media.created( + filename: generate_uuid_v4_filename(source_path), + original_name: basename(source_path), + mime_type: detect_mime_from_extension(source_path), + size: file_size(source_path), + width: extract_width_from_header(source_path), + height: extract_height_from_header(source_path), + file_path: format("media/{yyyy}/{mm}/{uuid}.{ext}"), + sidecar_path: format("media/{yyyy}/{mm}/{uuid}.{ext}.meta"), + checksum: sha256(source_path) + ) + ensures: ThumbnailsGenerated(media_id) + ensures: SearchIndexUpdated(media_id) +} + +-- ============================================================================ +-- MEDIA TAGGING +-- ============================================================================ + +invariant MediaTagsFormat { + -- Media tags are stored as JSON array in sidecar file + -- Tags are optional and only written if present + -- Same format as post tags +} + +rule TagMedia { + when: TagMediaRequested(media, tags) + ensures: media.tags = tags + ensures: SidecarFileUpdated(media) + ensures: SearchIndexUpdated(media) +} + +-- ============================================================================ +-- MEDIA DELETION +-- ============================================================================ + +rule DeleteMedia { + when: DeleteMediaRequested(media) + -- 1. Remove from database + -- 2. Delete binary file + -- 3. Delete sidecar file ({binary_path}.meta) + -- 4. Delete all four thumbnail files + -- 5. Delete translation sidecars ({binary_path}.{lang}.meta) + -- 6. Remove from search index + -- 7. Remove from all post links + ensures: FileDeleted(media.file_path) + ensures: FileDeleted(media.sidecar_path) + ensures: FileDeleted(media.thumbnails.small) + ensures: FileDeleted(media.thumbnails.medium) + ensures: FileDeleted(media.thumbnails.large) + ensures: FileDeleted(media.thumbnails.ai) + ensures: SearchIndexRemoved(media) + ensures: + for p in media.linked_posts: + p.linked_media = p.linked_media - {media} +} + +-- ============================================================================ +-- MEDIA VALIDATION +-- ============================================================================ + +rule ValidateMedia { + when: ValidateMediaRequested(project) + -- Check for: + -- 1. Missing binary files + -- 2. Missing sidecar files + -- 3. Missing thumbnails (all 4 sizes) + -- 4. Corrupted image files + -- 5. Orphan media (not linked to any post) + for m in project.media: + if not file_exists(m.file_path): + ensures: ValidationIssueReported(m, "missing_binary") + if not file_exists(m.sidecar_path): + ensures: ValidationIssueReported(m, "missing_sidecar") + if not file_exists(m.thumbnails.small): + ensures: ValidationIssueReported(m, "missing_thumbnail_small") + if not file_exists(m.thumbnails.medium): + ensures: ValidationIssueReported(m, "missing_thumbnail_medium") + if not file_exists(m.thumbnails.large): + ensures: ValidationIssueReported(m, "missing_thumbnail_large") + if not file_exists(m.thumbnails.ai): + ensures: ValidationIssueReported(m, "missing_thumbnail_ai") + if not is_valid_image(m.file_path): + ensures: ValidationIssueReported(m, "corrupted") + if not exists (p in Posts where m in p.linked_media): + ensures: ValidationIssueReported(m, "orphan") +} + +-- ============================================================================ +-- MEDIA SIDECAR FORMAT +-- ============================================================================ + +invariant MediaSidecarFormat { + -- Sidecar files use YAML-like key-value format (hand-built, not gray-matter) + -- Path: {binary_path}.meta + -- Only truthy fields are written (except required fields) + -- Fields: title, alt, caption, author, tags, language, linkedPostIds + -- Note: 'filename' is NOT written to sidecar (it is the binary filename itself) +} + +-- ============================================================================ +-- IMAGE OPTIMIZATION +-- ============================================================================ + +config { + -- No file size limit on import + -- Original files are stored as-is (no compression, no resize) + -- Only thumbnails are generated from the original + strip_exif: Boolean = false -- Not explicitly stripped; re-encoding naturally omits it +} + +-- ============================================================================ +-- MEDIA SEARCH INDEXING +-- ============================================================================ + +rule IndexMediaForSearch { + when: SearchIndexUpdated(media: media/Media) + -- Index fields: title, alt, caption, original_name, tags + -- Plus all translation titles, alts, and captions + let all_text = concat( + media.title, + media.alt, + media.caption, + media.original_name, + join(media.tags, " ") + ) + let stemmed = stem(all_text, detect_language(all_text)) + ensures: search/MediaSearchIndex.created( + media: media, + stemmed_content: stemmed + ) +} diff --git a/specs/menu.allium b/specs/menu.allium new file mode 100644 index 0000000..e072955 --- /dev/null +++ b/specs/menu.allium @@ -0,0 +1,56 @@ +-- allium: 1 +-- bDS Navigation Menu +-- Scope: core (read for rendering), extension Bucket F (menu editor UI) +-- Distilled from: src/main/engine/MenuEngine.ts + +surface MenuManagementSurface { + facing _: MenuOperator + + provides: + UpdateMenuRequested(menu, items) +} + +value MenuItem { + kind: page | submenu | category_archive | home + label: String + slug: String? + children: List? -- only for submenu kind +} + +entity Menu { + items: List + + -- Derived + home_items: items where kind = home + home_entry: home_items.first +} + +surface MenuSurface { + context menu: Menu + + exposes: + menu.items.count + menu.home_items.count + menu.home_entry.label +} + +invariant HomeAlwaysPresent { + -- The menu always has a Home entry, extracted and prepended + for menu in Menus: + menu.items.first.kind = home +} + +invariant MenuPersistedAsOpml { + -- meta/menu.opml is the canonical storage format + -- Uses OPML with outline elements for each item + parse_opml(read_file("meta/menu.opml")) = menu.items +} + +rule UpdateMenu { + when: UpdateMenuRequested(menu, items) + -- Normalizes Home entry: extracts from items, prepends + let without_home = items where kind != home + let home = MenuItem{kind: home, label: "Home"} + ensures: menu.items = build_menu_items(home, without_home) + ensures: MenuFileWritten(menu) +} diff --git a/specs/metadata.allium b/specs/metadata.allium new file mode 100644 index 0000000..5d6d2ec --- /dev/null +++ b/specs/metadata.allium @@ -0,0 +1,125 @@ +-- allium: 1 +-- bDS Project Metadata, Categories, Publishing Preferences +-- Scope: core (Wave 1) +-- Distilled from: src/main/engine/MetaEngine.ts, schema.ts + +use "./project.allium" as project + +surface MetadataControlSurface { + facing _: MetadataOperator + + provides: + UpdateProjectMetadataRequested(project, changes) + AddCategoryRequested(project, name) + RemoveCategoryRequested(project, name) + UpdateCategorySettingsRequested(project, category, settings) + SetPublishingPreferencesRequested(project, prefs) + AppStarted(project) +} + +surface PublishingPreferencesSurface { + context prefs: PublishingPreferences + + exposes: + prefs.ssh_host when prefs.ssh_host != null + prefs.ssh_user when prefs.ssh_user != null + prefs.ssh_remote_path when prefs.ssh_remote_path != null + prefs.ssh_mode +} + +value CategoryRenderSettings { + render_in_lists: Boolean + show_title: Boolean + post_template_slug: String? + list_template_slug: String? +} + +entity ProjectMetadata { + project: project/Project + name: String + description: String? + public_url: String? + main_language: String? -- ISO 639-1 + default_author: String? + max_posts_per_page: Integer -- 1..500, default 50 + blogmark_category: String? + pico_theme: String? -- 12+ named Pico CSS themes + semantic_similarity_enabled: Boolean + blog_languages: Set -- subset of supported languages + categories: Set -- category names + category_settings: Set +} + +entity PublishingPreferences { + ssh_host: String? + ssh_user: String? + ssh_remote_path: String? + ssh_mode: scp | rsync +} + +invariant DefaultCategories { + -- New projects start with: article, picture, aside, page + -- These are defaults, not invariants — user can remove them +} + +invariant MetadataPersistedAsFiles { + -- Four separate JSON files in meta/: + -- meta/project.json — name, description, publicUrl, mainLanguage, etc. + -- meta/categories.json — sorted category list + -- meta/category-meta.json — per-category render settings + -- meta/publishing.json — SSH connection details (non-secret) + -- All writes are atomic (temp file + rename) +} + +config { + default_max_posts_per_page: Integer = 50 + min_posts_per_page: Integer = 1 + max_posts_per_page: Integer = 500 + default_categories: Set = {"article", "picture", "aside", "page"} + supported_pico_themes: Set = { + "default", "amber", "blue", "cyan", "fuchsia", "green", + "grey", "indigo", "jade", "lime", "orange", "pink", + "pumpkin", "purple", "red", "sand", "slate", "violet", + "yellow", "zinc" + } +} + +rule UpdateProjectMetadata { + when: UpdateProjectMetadataRequested(project, changes) + ensures: MetadataFieldsUpdated(project, changes) + ensures: ProjectJsonWritten(project) +} + +rule AddCategory { + when: AddCategoryRequested(project, name) + requires: not (name in project.metadata.categories) + ensures: project.metadata.categories = project.metadata.categories + {name} + ensures: CategoriesJsonWritten(project) +} + +rule RemoveCategory { + when: RemoveCategoryRequested(project, name) + ensures: project.metadata.categories = project.metadata.categories - {name} + ensures: CategorySettingsRemoved(project, name) + ensures: CategoriesJsonWritten(project) + ensures: CategoryMetaJsonWritten(project) +} + +rule UpdateCategorySettings { + when: UpdateCategorySettingsRequested(project, category, settings) + ensures: CategorySettingsUpdated(project, category, settings) + ensures: CategoryMetaJsonWritten(project) +} + +rule SetPublishingPreferences { + when: SetPublishingPreferencesRequested(project, prefs) + ensures: project.publishing_preferences = prefs + ensures: PublishingJsonWritten(project) +} + +rule StartupSync { + when: AppStarted(project) + -- Loads metadata from filesystem, merges with DB, + -- creates defaults for new projects + ensures: ProjectMetadata.synced_from_filesystem(project) +} diff --git a/specs/metadata_diff.allium b/specs/metadata_diff.allium new file mode 100644 index 0000000..0b546fb --- /dev/null +++ b/specs/metadata_diff.allium @@ -0,0 +1,81 @@ +-- allium: 1 +-- bDS Metadata Diff and Rebuild +-- Scope: core (Wave 1) +-- Distilled from: src/main/engine/MetadataDiffEngine.ts + +use "./post.allium" as post +use "./media.allium" as media +use "./script.allium" as script +use "./template.allium" as template + +surface MetadataMaintenanceSurface { + facing _: MaintenanceOperator + + provides: + MetadataDiffRequested(project) + RebuildFromFilesystemRequested(project, entity_type) +} + +value DiffField { + name: String + db_value: String + file_value: String +} + +value DiffReport { + entity_type: String -- post, media, script, template + entity_id: String + differences: List +} + +value OrphanReport { + file_path: String + -- File exists on disk but has no DB record +} + +rule RunMetadataDiff { + when: MetadataDiffRequested(project) + -- Runs as background task via TaskManager + -- Compares DB records against filesystem files for: + -- posts, translations, media, scripts, templates + -- Detected fields: tags, categories, title, excerpt, author, + -- language, status, templateSlug, dates + for post in project.posts: + let file_data = parse_post_file(post.file_path) + let diffs = compare_fields(post, file_data) + if diffs.count > 0: + ensures: DiffReport.created(entity_type: "post", entity_id: post.id, differences: diffs) + + -- Detect orphan files (on disk but not in DB) + for file in scan_directory(project.effective_data_dir + "/posts", "*.md"): + let matching = Posts where file_path = file + if matching.count = 0: + ensures: OrphanReport.created(file_path: file) + + -- Same pattern for media sidecar files, scripts, templates +} + +rule RebuildFromFilesystem { + when: RebuildFromFilesystemRequested(project, entity_type) + -- The inverse of metadata diff: filesystem is treated as truth + -- Reads all files and upserts into DB + ensures: + if entity_type = "post": + post/RebuildPostsFromFiles(project) + if entity_type = "media": + media/RebuildMediaFromFiles(project) + if entity_type = "script": + script/RebuildScriptsFromFiles(project) + if entity_type = "template": + template/RebuildTemplatesFromFiles(project) +} + +invariant ThreeWaySync { + -- Metadata must stay in sync across three representations: + -- 1. Database records + -- 2. Filesystem files (frontmatter/sidecars) + -- 3. Generated site output + -- MetadataDiff detects divergence between (1) and (2) + -- Rebuild resolves divergence by treating (2) as truth + -- Site generation consumes (1) to produce (3) +} diff --git a/specs/modals.allium b/specs/modals.allium new file mode 100644 index 0000000..a661e96 --- /dev/null +++ b/specs/modals.allium @@ -0,0 +1,251 @@ +-- allium: 1 +-- bDS Shared Modals +-- Scope: UI overlays used across multiple editor views +-- Distilled from: PostEditor.tsx, MediaEditor.tsx + +-- Shared modal components used by multiple editors. +-- Editor-specific modals live in their respective editor spec files. + +use "./i18n.allium" as i18n + +-- ─── AI Suggestions Modal ──────────────────────────────────── + +-- Shared modal for presenting AI suggestions with per-field accept/reject. +-- Used by PostAIAnalysis (editor_post.allium) and +-- MediaAIImageAnalysis (editor_media.allium). + +value AISuggestionsModal { + fields: List + -- Layout: title bar ("AI Suggestions"), scrollable field list, button row + -- Each field rendered as a row: + -- Left: checkbox (accept/reject), label + -- Center: current value (read-only, muted), arrow, suggested value (highlighted) + -- Special: slug field checkbox disabled if post was ever published + -- Buttons: Cancel (secondary), Apply Selected (primary) + -- Cancel discards all; Apply writes only accepted fields to entity +} + +surface AISuggestionsModalSurface { + context modal: AISuggestionsModal + + exposes: + modal.fields.count +} + +value AISuggestionField { + label: String + current_value: String + suggested_value: String + accepted: Boolean -- checkbox, default true + locked: Boolean -- if true, checkbox disabled (e.g. published slug) +} + +-- ─── Insert Post Link Modal ────────────────────────────────── + +value InsertPostLinkModal { + -- Two-tab modal opened by Ctrl/Cmd+K in post editor (markdown mode). + -- Tab 1 - Internal: + -- Search input (debounced 300ms, queries post titles via FTS) + -- + -- Empty query state (search_query < 2 chars): + -- If semanticSimilarityEnabled: shows up to 5 related posts via + -- FindSimilar(current_post, 5) ranked by embedding similarity + -- Else: shows nothing (empty results) + -- + -- Active query state (search_query >= 2 chars): + -- Results from FTS title search + -- If semanticSimilarityEnabled: each result augmented with similarity + -- score from ComputeSimilarities(current_post, result_post_ids) + -- Scores displayed as visual indicator per result row + -- Results list: post title + status badge + optional similarity score + -- + -- Click result: inserts [title](/YYYY/MM/DD/slug) at cursor, closes modal + -- "Create Post" row at bottom of results: + -- Creates new post with search query as title, inserts link to it + -- Tab 2 - External: + -- URL input field (required) + -- Display text input field (optional) + -- Insert button: inserts [text](url) or bare url if no text, closes modal + active_tab: String -- internal | external + search_query: String + results: List + related_posts: List -- similarity-based, shown when query empty +} + +surface InsertPostLinkModalSurface { + context modal: InsertPostLinkModal + + exposes: + modal.active_tab + modal.search_query + modal.results.count + modal.related_posts.count +} + +value InsertLinkResult { + post_id: String + title: String + status: String -- draft | published | archived + canonical_url: String -- /YYYY/MM/DD/slug + similarity_score: Decimal? -- 0.0-1.0, present when embeddings enabled +} + +-- ─── Insert Media Modal ────────────────────────────────────── + +value InsertMediaModal { + -- Grid modal for inserting media references into post content. + -- Search input filtering by media title and original filename. + -- Grid of media items: bds-thumb:// thumbnail (medium 400px), title below. + -- Click item: + -- Images: inserts ![alt](bds-media://id) at cursor + -- Non-images: inserts [originalName](bds-media://id) at cursor + -- Closes modal after insertion. + search_query: String + results: List +} + +surface InsertMediaModalSurface { + context modal: InsertMediaModal + + exposes: + modal.search_query + modal.results.count +} + +value InsertMediaResult { + media_id: String + title: String + original_name: String + is_image: Boolean + thumbnail_url: String? -- bds-thumb:// for images, null for others +} + +-- ─── Language Picker Modal ─────────────────────────────────── + +value LanguagePickerModal { + -- Shown for Translate Post and Translate Media Metadata actions. + -- Lists all configured blogLanguages except source language. + -- Each row: flag emoji, language name, status badge if translation exists. + -- Existing translations show "(draft)" or "(published)" badge. + -- Click selects target language and initiates translation flow. + -- Cancel closes without action. + source_language: String + available_targets: List +} + +surface LanguagePickerModalSurface { + context modal: LanguagePickerModal + + exposes: + modal.source_language + modal.available_targets.count +} + +value LanguageTarget { + code: String + name: String + flag_emoji: String + has_existing_translation: Boolean + existing_status: String? -- draft | published, if translation exists +} + +-- ─── Confirm Delete Modal ──────────────────────────────────── + +value ConfirmDeleteModal { + -- Custom styled modal for destructive operations with reference info. + -- Used by: MediaDelete (shows linked posts), TagDelete (shows post count). + -- Layout: warning icon, title, entity name, reference section, buttons. + -- Reference section: "This item is referenced by:" + bulleted list. + -- Buttons: Cancel (secondary), Delete (destructive red). + entity_name: String + entity_type: String -- media | tag + reference_count: Integer + reference_list: List -- titles of referencing entities +} + +surface ConfirmDeleteModalSurface { + context modal: ConfirmDeleteModal + + exposes: + modal.entity_name + modal.entity_type + modal.reference_count + modal.reference_list +} + +-- ─── Confirm Dialog ────────────────────────────────────────── + +value ConfirmDialog { + -- Custom styled modal for non-delete confirmations. + -- Used by: TagMerge ("Merge N tags into {target}? Cannot be undone."). + -- Layout: title, descriptive message, buttons. + -- Buttons: Cancel (secondary), Confirm (primary). + title: String + message: String +} + +surface ConfirmDialogSurface { + context modal: ConfirmDialog + + exposes: + modal.title + modal.message +} + +-- System confirm dialogs are NOT modelled as values. +-- They are simple yes/no system dialogs with a message string. +-- Used by: PostDelete, PostDiscard, TemplateDelete (with references). + +-- ─── Gallery Overlay ───────────────────────────────────────── + +value GalleryOverlay { + -- Full-screen overlay showing all media linked to a post. + -- Opened from Gallery button in post editor toolbar (markdown mode). + -- Image grid: bds-thumb:// thumbnails (medium 400px), 3-4 columns. + -- Click image: opens LightboxView for that image. + -- Close: X button or ESC key. + post_id: String + images: List +} + +surface GalleryOverlaySurface { + context overlay: GalleryOverlay + + exposes: + overlay.post_id + overlay.images.count +} + +value GalleryImage { + media_id: String + thumbnail_url: String -- bds-thumb://media_id + alt_text: String? +} + +value LightboxView { + -- Full-screen image viewer, sub-view of GalleryOverlay. + -- Shows single image at full resolution via bds-media:// protocol. + -- Navigation: left/right arrow buttons, keyboard left/right arrow keys. + -- Close: X button, ESC key, or click outside image area. + -- Header: image title or filename, index counter "3 of 12". + current_index: Integer + total_count: Integer + media_id: String + image_url: String -- bds-media://media_id + alt_text: String? +} + +surface LightboxViewSurface { + context view: LightboxView + + exposes: + view.current_index + view.total_count + view.media_id + view.image_url + view.alt_text when view.alt_text != null +} + +-- All modals rendered as centered overlay with backdrop dimming. +-- ESC key or backdrop click closes modal (cancel semantics). +-- Overlays (PostPicker, ColourPicker) are positioned inline near trigger. diff --git a/specs/post.allium b/specs/post.allium new file mode 100644 index 0000000..6782be9 --- /dev/null +++ b/specs/post.allium @@ -0,0 +1,234 @@ +-- allium: 1 +-- bDS Post Lifecycle +-- Scope: core (Wave 1) +-- Distilled from: src/main/engine/PostEngine.ts, postFileUtils.ts, schema.ts + +use "./project.allium" as project + +value Slug { + value: String + + -- Generated by: transliterate unicode to ASCII, lowercase, + -- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens + -- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used. + -- Verify transliteration matches the established bDS behaviour for this set. + -- Uniqueness: tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp} +} + +value PostFilePath { + -- posts/YYYY/MM/{slug}.md + -- YYYY and MM derived from created_at + base_dir: String + year: String + month: String + slug: Slug +} + +value PostCanonicalUrl { + -- /{YYYY}/{MM}/{DD}/{slug} + -- YYYY/MM/DD from created_at (zero-padded) + year: String + month: String + day: String + slug: Slug +} + +value Frontmatter { + -- YAML between --- delimiters at start of .md file + -- Always present: id, title, slug, status, createdAt, updatedAt, tags, categories + -- Optional (written only when truthy): excerpt, author, language, + -- doNotTranslate (only when true), templateSlug, publishedAt +} + +surface PostControlSurface { + facing _: PostOperator + + provides: + CreatePostRequested(project, title, content, tags, categories, author, language, template_slug) + UpdatePostRequested(post, changes) + PublishPostRequested(post) + DeletePostRequested(post) + ArchivePostRequested(post) +} + +surface PostFilePathSurface { + context path: PostFilePath + + exposes: + path.base_dir + path.year + path.month + path.slug +} + +surface PostCanonicalUrlSurface { + context url: PostCanonicalUrl + + exposes: + url.year + url.month + url.day + url.slug +} + +surface FrontmatterSurface { + context _: Frontmatter +} + +entity Post { + project: project/Project + title: String + slug: Slug + excerpt: String? + content: String? + status: draft | published | archived + author: String? + language: String? + do_not_translate: Boolean + template_slug: String? + file_path: String + checksum: String? + tags: List + categories: List + created_at: Timestamp + updated_at: Timestamp + published_at: Timestamp? + + -- Relationships + translations: PostTranslation with canonical_post = this + linked_media: PostMediaLink with post = this + outgoing_links: PostLink with source = this + incoming_links: PostLink with target = this + + -- Derived + available_languages: translations -> language + is_slug_frozen: published_at != null + -- 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. + + transitions status { + draft -> published + draft -> archived + published -> draft + published -> archived + archived -> draft + archived -> published + } +} + +entity PostLink { + source: Post + target: Post + link_text: String? +} + +entity PostMediaLink { + post: Post + media_id: String + sort_order: Integer +} + +invariant UniqueSlugPerProject { + for a in Posts: + for b in Posts: + (a != b and a.project = b.project) implies a.slug != b.slug +} + +rule CreatePost { + when: CreatePostRequested(project, title, content, tags, categories, author, language, template_slug) + let slug = Slug.generate(title ?? "untitled") + let unique_slug = Slug.ensure_unique(slug, project) + ensures: + let new_post = Post.created( + project: project, + title: title ?? "", + slug: unique_slug, + content: content, + status: draft, + author: author, + language: language, + tags: tags ?? {}, + categories: categories ?? {}, + template_slug: template_slug, + do_not_translate: false, + file_path: "" + ) + new_post.status = draft + SearchIndexUpdated(new_post) +} + +rule UpdatePost { + when: UpdatePostRequested(post, changes) + requires: not post.is_slug_frozen or changes.slug = null + -- Cannot change slug after first publish + ensures: post.updated_at = now + ensures: PostFieldsUpdated(post, changes) + ensures: SearchIndexUpdated(post) + @guidance + -- If post is published and content/metadata changed, + -- status auto-transitions back to draft +} + +rule ReopenPublishedPost { + when: UpdatePostRequested(post, changes) + requires: post.status = published + requires: changes_affect_published_content(changes) + ensures: post.status = draft +} + +rule PublishPost { + when: PublishPostRequested(post) + requires: post.status = draft or post.status = archived + ensures: post.status = published + ensures: post.published_at = post.published_at ?? now + -- Preserve original publish date on re-publish + ensures: PostFileWritten(post) + -- Writes frontmatter + markdown to posts/YYYY/MM/{slug}.md + ensures: post.content = null + -- Content cleared from DB; now lives in filesystem only + ensures: SearchIndexUpdated(post) + ensures: PostLinksUpdated(post) + -- Parse inter-post links, update link graph + ensures: + for t in post.translations: + TranslationFileWritten(t) +} + +rule DeletePost { + when: DeletePostRequested(post) + ensures: not exists post + ensures: PostFileDeleted(post) + -- Remove .md file if it exists + ensures: + for t in post.translations: + not exists t + ensures: SearchIndexUpdated(post) +} + +rule ArchivePost { + when: ArchivePostRequested(post) + requires: post.status = draft or post.status = published + ensures: post.status = archived +} + +-- File format axioms + +invariant FrontmatterRoundtrip { + -- Reading a post file written by the system produces identical + -- field values to the database record at time of writing + for post in Posts where status = published: + parse_frontmatter(read_file(post.file_path)) = frontmatter_fields(post) +} + +invariant DateBasedFileLayout { + for post in Posts where file_path != "": + post.file_path = format("posts/{yyyy}/{mm}/{slug}.md", + yyyy: post.created_at.year, + mm: post.created_at.month_padded, + slug: post.slug) +} + +-- Slug freeze: once published_at is set, the slug is permanently frozen. +-- This follows the established bDS rule: is_slug_frozen = published_at != null +-- Even if the post reverts to draft, the slug cannot be changed. diff --git a/specs/preview.allium b/specs/preview.allium new file mode 100644 index 0000000..cd0bd15 --- /dev/null +++ b/specs/preview.allium @@ -0,0 +1,108 @@ +-- allium: 1 +-- bDS Local Preview Server +-- Scope: core (Wave 4) +-- Distilled from: src/main/engine/PreviewServer.ts, PageRenderer.ts + +use "./template.allium" as template +use "./generation.allium" as generation + +entity PreviewServer { + host: String -- 127.0.0.1 + port: Integer -- 4123 + is_running: Boolean +} + +config { + preview_host: String = "127.0.0.1" + preview_port: Integer = 4123 +} + +surface PreviewControlSurface { + facing _: PreviewOperator + + provides: + StartPreviewRequested(project) + StopPreviewRequested(server) +} + +surface PreviewHttpSurface { + facing _: PreviewClient + + provides: + PreviewRequest(path) + PreviewDraftRequest(path, post_id) +} + +rule StartPreview { + when: StartPreviewRequested(project) + ensures: PreviewServer.created( + host: config.preview_host, + port: config.preview_port, + is_running: true + ) +} + +rule StopPreview { + when: StopPreviewRequested(server) + -- Graceful shutdown with inflight request tracking + ensures: server.is_running = false +} + +-- Route resolution + +rule ServePostPreview { + when: PreviewRequest(path) + requires: is_post_path(path) + -- path matches "/{yyyy}/{mm}/{dd}/{slug}" + -- Renders post via Liquid template with full PageRenderer context + ensures: PreviewResponse(rendered_html) +} + +rule ServeDraftPreview { + when: PreviewDraftRequest(path, post_id) + -- Renders draft content (from DB, not filesystem) + ensures: PreviewResponse(rendered_html) +} + +rule ServeArchivePreview { + when: PreviewRequest(path) + requires: is_archive_path(path) + -- Category, tag, date archives with pagination + ensures: PreviewResponse(rendered_html) +} + +rule ServeMediaFile { + when: PreviewRequest(path) + requires: is_media_path(path) + -- Path-traversal protection: validates path stays within media directory + ensures: PreviewResponse(media_file) +} + +rule ServeAssets { + when: PreviewRequest(path) + requires: is_asset_path(path) + ensures: PreviewResponse(asset_file) +} + +rule ServeLanguagePrefixedRoute { + when: PreviewRequest(path) + requires: is_language_prefixed(path) + -- Detects language prefix from supported languages + -- Renders with translation overlay for that language + ensures: PreviewResponse(translated_html) +} + +invariant ThemeSwitching { + -- Preview supports live theme/mode switching via query params + -- ?theme=amber&mode=dark etc. + -- Uses Pico CSS with configurable themes +} + +invariant PreviewServerBinding { + for server in PreviewServers where server.is_running: + server.host = config.preview_host and server.port = config.preview_port +} + +invariant LocalhostOnly { + -- Preview server binds to 127.0.0.1 only, never 0.0.0.0 +} diff --git a/specs/project.allium b/specs/project.allium new file mode 100644 index 0000000..864027a --- /dev/null +++ b/specs/project.allium @@ -0,0 +1,110 @@ +-- allium: 1 +-- bDS Project Management +-- Scope: core (Wave 1) +-- Distilled from: src/main/engine/ProjectEngine.ts, schema.ts + +surface ProjectControlSurface { + facing _: ProjectOperator + + provides: + CreateProjectRequested(name, data_path) + SetActiveProjectRequested(project) + DeleteProjectRequested(project) +} + +entity Project { + name: String + slug: String + description: String? + data_path: String? + is_active: Boolean + created_at: Timestamp + updated_at: Timestamp + + -- Relationships + posts: Post with project = this + media: Media with project = this + tags: Tag with project = this + + -- Derived + internal_base_dir: String + -- {user_data}/projects/{id}/ + -- Contains: meta/, thumbnails/, tags.json + effective_data_dir: data_path ?? internal_base_dir + -- Custom data path overrides default +} + +surface ProjectSurface { + context project: Project + + exposes: + project.name + project.slug + project.description when project.description != null + project.data_path when project.data_path != null + project.is_active + project.created_at + project.updated_at + project.posts.count + project.media.count + project.tags.count + project.internal_base_dir + project.effective_data_dir +} + +invariant SingleActiveProject { + -- Exactly one project is active at any time + let active = Projects where is_active + active.count = 1 +} + +invariant UniqueProjectSlug { + for a in Projects: + for b in Projects: + a != b implies a.slug != b.slug +} + +rule CreateProject { + when: CreateProjectRequested(name, data_path) + let slug = slugify(name) + ensures: Project.created( + name: name, + slug: slug, + data_path: data_path, + is_active: false + ) + ensures: StarterTemplatesCopied(project) + -- Bundled starter templates are copied into the new project +} + +rule SetActiveProject { + when: SetActiveProjectRequested(project) + let previous = Projects where is_active = true + ensures: + for p in previous: + p.is_active = false + ensures: project.is_active = true +} + +rule DeleteProject { + when: DeleteProjectRequested(project) + requires: project.id != "default" + -- The default project (id='default') cannot be deleted + requires: project.is_active = false + -- The currently active project cannot be deleted + ensures: not exists project + @guidance + -- deleteProjectWithData removes DB rows + internal directory + -- but preserves external data at custom data_path +} + +config { + default_project_id: String = "default" + default_project_name: String = "My Blog" +} + +invariant DefaultProjectExists { + -- A project with id='default' always exists + -- It is created on first launch if missing + exists p in Projects where p.id = "default" +} diff --git a/specs/publishing.allium b/specs/publishing.allium new file mode 100644 index 0000000..6bc2ae2 --- /dev/null +++ b/specs/publishing.allium @@ -0,0 +1,140 @@ +-- allium: 1 +-- bDS SSH Publishing +-- Scope: core (Wave 5) +-- Distilled from: src/main/engine/PublishEngine.ts + +use "./metadata.allium" as meta + +entity PublishJob { + ssh_host: String + ssh_user: String + ssh_remote_path: String + ssh_mode: scp | rsync + status: pending | running | completed | failed + + transitions status { + pending -> running + running -> completed + running -> failed + } +} + +value UploadTarget { + kind: html | thumbnails | media + local_dir: String + remote_dir: String +} + +surface PublishJobSurface { + context job: PublishJob + + exposes: + job.ssh_host + job.ssh_user + job.ssh_remote_path + job.ssh_mode + job.status +} + +surface UploadTargetSurface { + context target: UploadTarget + + exposes: + target.kind + target.local_dir + target.remote_dir +} + +surface PublishingControlSurface { + facing _: PublishOperator + + provides: + UploadSiteRequested(project, credentials) +} + +surface PublishingRuntimeSurface { + facing _: PublishRuntime + + provides: + PublishJobStarted(project, job, credentials) + PublishTargetFailed(job, target, error) +} + +rule UploadSite { + when: UploadSiteRequested(project, credentials) + ensures: + let job = PublishJob.created( + ssh_host: credentials.ssh_host, + ssh_user: credentials.ssh_user, + ssh_remote_path: credentials.ssh_remote_path, + ssh_mode: credentials.ssh_mode, + status: pending + ) + job.status = pending + PublishJobStarted(project, job, credentials) +} + +rule StartPublishJob { + when: PublishJobStarted(project, job, credentials) + requires: job.status = pending + ensures: job.status = running + ensures: UploadTargetStarted(job, html, "html/", credentials.ssh_remote_path, credentials) + ensures: UploadTargetStarted(job, thumbnails, "thumbnails/", credentials.ssh_remote_path + "/thumbnails", credentials) + ensures: UploadTargetStarted(job, media, "media/", credentials.ssh_remote_path + "/media", credentials) +} + +rule UploadViaScp { + when: UploadTargetStarted(job, target, local_dir, remote_dir, credentials) + requires: credentials.ssh_mode = scp + -- mtime-based upload detection: skip unchanged files + -- Uses SSH agent (SSH_AUTH_SOCK) for authentication + ensures: ScpUploadCompleted(job, target) + ensures: UploadTargetCompleted(job, target) +} + +rule UploadViaRsync { + when: UploadTargetStarted(job, target, local_dir, remote_dir, credentials) + requires: credentials.ssh_mode = rsync + -- rsync --update --compress --verbose + -- Media uploads exclude .meta sidecar files + ensures: RsyncUploadCompleted(job, target) + ensures: UploadTargetCompleted(job, target) + + @guidance + -- rsync exclude filters for .meta files on media target +} + +rule CompletePublishJob { + when: PublishTargetsCompleted(job) + requires: job.status = running + ensures: job.status = completed +} + +rule FailPublishJob { + when: PublishTargetFailed(job, target, error) + requires: job.status = running + ensures: job.status = failed +} + +rule TrackUploadCompletion { + when: UploadTargetCompleted(job, target) + requires: all_upload_targets_completed(job) + ensures: PublishTargetsCompleted(job) +} + +invariant MediaSidecarsExcludedFromUpload { + -- .meta sidecar files are never uploaded to the remote server + -- They are project metadata, not public content +} + +invariant PublishJobLifecycle { + -- UploadSiteRequested creates one PublishJob in pending state. + -- PublishJobStarted moves the job to running before any target starts. + -- A job reaches completed only after PublishTargetsCompleted(job). + -- Any PublishTargetFailed(job, target, error) transitions the job to failed. +} + +invariant SshAgentAuth { + -- Publishing uses SSH_AUTH_SOCK for key-based authentication + -- No password prompts, no interactive auth +} diff --git a/specs/schema.allium b/specs/schema.allium new file mode 100644 index 0000000..d6b428f --- /dev/null +++ b/specs/schema.allium @@ -0,0 +1,713 @@ +-- allium: 1 +-- bDS Persistence Data Contract +-- Scope: core (Wave 1 — exact compatibility contract) +-- Distilled from: ../bDS/src/main/database/schema.ts +-- +-- This document specifies the persisted data model the rewrite must be able +-- to read and write. It is the ground truth for storage compatibility. + +-- ============================================================================ +-- CORE ENTITIES +-- ============================================================================ + +entity Project { + id: String -- UUID v4 + name: String -- Display name + slug: String -- URL-safe identifier + description: String? -- Optional description + data_path: String? -- Custom data directory (null = default) + created_at: Timestamp -- Unix timestamp + updated_at: Timestamp -- Unix timestamp + is_active: Boolean -- Exactly one project is active at a time +} + +entity Post { + id: String -- UUID v4 + project_id: String + title: String + slug: String -- URL-friendly identifier + excerpt: String? -- Optional summary + content: String? -- Draft body (null when published) + status: draft | published | archived + author: String? -- Author name + created_at: Timestamp + updated_at: Timestamp + published_at: Timestamp? + file_path: String -- Empty for never-published drafts + checksum: String? -- SHA-256 of content + tags: Set -- JSON array stored as text + categories: Set -- JSON array stored as text + template_slug: String? -- User template override + language: String? -- ISO 639-1 code + do_not_translate: Boolean + + -- Published snapshot columns (written on publish for diff detection) + published_title: String? + published_content: String? + published_tags: String? + published_categories: String? + published_excerpt: String? +} + +entity PostTranslation { + id: String -- UUID v4 + project_id: String + translation_for: String -- Canonical post ID + language: String -- ISO 639-1 code + title: String + excerpt: String? + content: String? -- Draft body (null when published) + status: draft | published + created_at: Timestamp + updated_at: Timestamp + published_at: Timestamp? + file_path: String + checksum: String? +} + +entity Media { + id: String -- UUID v4 + project_id: String + filename: String -- Generated filename + original_name: String -- Original uploaded filename + mime_type: String -- e.g. "image/jpeg" + size: Integer -- Bytes + width: Integer? -- Image dimensions + height: Integer? + title: String? + alt: String? + caption: String? + author: String? + file_path: String -- Absolute path to binary + sidecar_path: String -- Path to .meta sidecar file + created_at: Timestamp + updated_at: Timestamp + checksum: String? + tags: Set -- JSON array stored as text + language: String? -- ISO 639-1 code +} + +entity MediaTranslation { + id: String -- UUID v4 + project_id: String + translation_for: String -- Canonical media ID + language: String -- ISO 639-1 code + title: String? + alt: String? + caption: String? + created_at: Timestamp + updated_at: Timestamp +} + +entity Tag { + id: String -- UUID v4 + project_id: String + name: String -- Case-insensitive unique per project + color: String? -- Hex color like #ff0000 + post_template_slug: String? -- Template override for this tag + created_at: Timestamp + updated_at: Timestamp +} + +entity Template { + id: String -- UUID v4 + project_id: String + slug: String -- URL-safe identifier + title: String + kind: post | list | not_found | partial + enabled: Boolean + version: Integer -- Incremented on each update + file_path: String -- templates/{slug}.liquid + status: draft | published + content: String? -- Draft body (null when published) + created_at: Timestamp + updated_at: Timestamp +} + +entity Script { + id: String -- UUID v4 + project_id: String + slug: String -- URL-safe identifier + title: String + kind: macro | utility | transform + entrypoint: String -- Default: "render" for macros + enabled: Boolean + version: Integer -- Incremented on each update + file_path: String -- scripts/{slug}.{extension} + status: draft | published + content: String? -- Draft body (null when published) + created_at: Timestamp + updated_at: Timestamp +} + +-- ============================================================================ +-- RELATIONSHIP TABLES +-- ============================================================================ + +entity PostLink { + id: String -- UUID v4 + source_post_id: String -- Post containing the link + target_post_id: String -- Post being linked to + link_text: String? -- Anchor text + created_at: Timestamp +} + +entity PostMediaLink { + id: String -- UUID v4 + project_id: String + post_id: String + media_id: String + sort_order: Integer -- For ordering media within a post + created_at: Timestamp +} + +-- ============================================================================ +-- METADATA TABLES +-- ============================================================================ + +entity Setting { + key: String -- Primary key + value: String -- Serialized value + updated_at: Timestamp +} + +entity GeneratedFileHash { + project_id: String + relative_path: String + content_hash: String -- SHA-256 of file content + updated_at: Timestamp +} + +-- ============================================================================ +-- SEARCH INDEX (FTS5 Virtual Tables) +-- ============================================================================ + +entity PostSearchIndex { + -- Full-text search index projection, not a user-authored entity + -- Indexed fields: title, excerpt, content, tags, categories + -- Plus all translation titles, excerpts, and content + post: Post + stemmed_content: String -- Processed via Snowball stemmer +} + +entity MediaSearchIndex { + -- Full-text search index projection + -- Indexed fields: title, alt, caption, original_name, tags + -- Plus all translation titles, alts, and captions + media: Media + stemmed_content: String -- Processed via Snowball stemmer +} + +-- ============================================================================ +-- AI / CHAT TABLES +-- ============================================================================ + +entity ChatConversation { + id: String -- UUID v4 + title: String + model: String? -- Model used for conversation + copilot_session_id: String? -- Legacy, no longer used + created_at: Timestamp + updated_at: Timestamp +} + +entity ChatMessage { + id: Integer -- Auto-increment + conversation_id: String + role: system | user | assistant | tool + content: String? + tool_call_id: String? -- For tool responses + tool_calls: String? -- JSON array of tool calls + created_at: Timestamp +} + +entity AiProvider { + -- Provider catalog, populated from upstream model registry. + -- Managed by the application and treated as read-only during normal use. + id: String -- PRIMARY KEY + name: String + env: String? -- Environment variable for API key + package_ref: String? -- Legacy package reference + api: String? -- Base API URL + doc: String? -- Documentation URL + updated_at: Timestamp +} + +entity AiModel { + -- Full model catalog with capability metadata. + -- Composite primary key: (provider, model_id). + provider: AiProvider + model_id: String + name: String + family: String? + attachment: Boolean -- supports file attachments + reasoning: Boolean -- supports chain-of-thought + tool_call: Boolean -- supports tool/function calling + structured_output: Boolean + temperature: Boolean -- supports temperature parameter + knowledge: String? -- training data cutoff + release_date: String? + last_updated_date: String? + open_weights: Boolean + input_price: Integer? -- price per million input tokens + output_price: Integer? -- price per million output tokens + cache_read_price: Integer? + cache_write_price: Integer? + context_window: Integer + max_input_tokens: Integer + max_output_tokens: Integer + interleaved: String? -- interleaved capability descriptor + status: String? -- active | deprecated | preview + provider_package_ref: String? -- provider-specific legacy package reference + updated_at: Timestamp +} + +entity AiModelModality { + -- Input/output modality declarations per model. + provider: AiProvider + model_id: String + direction: String -- "input" | "output" + modality: String -- "text" | "image" | "audio" | "video" +} + +entity AiCatalogMeta { + key: String -- "{endpoint_kind}_etag" | "{endpoint_kind}_lastFetchedAt" + value: String +} + +-- ============================================================================ +-- EMBEDDINGS TABLES +-- ============================================================================ + +entity EmbeddingKey { + label: Integer -- USearch bigint key + post_id: String + project_id: String + content_hash: String -- SHA-256 of title+content + vector: String -- Encoded vector payload (1536 bytes for 384-dim) +} + +entity DismissedDuplicatePair { + id: String -- UUID v4 + project_id: String + post_id_a: String + post_id_b: String + dismissed_at: Timestamp +} + +-- ============================================================================ +-- IMPORT TABLES +-- ============================================================================ + +entity ImportDefinition { + id: String -- UUID v4 + project_id: String + name: String + wxr_file_path: String? -- WordPress XML export file + uploads_folder_path: String? -- WordPress uploads directory + last_analysis_result: String? -- JSON text of ImportAnalysisReport + created_at: Timestamp + updated_at: Timestamp +} + +-- ============================================================================ +-- NOTIFICATION TABLES +-- ============================================================================ + +entity DbNotification { + id: Integer -- Auto-increment + entity_type: String -- 'post' | 'media' | 'script' | 'template' + entity_id: String + action: created | updated | deleted + from_cli: Boolean -- 1 = written by CLI + seen_at: Timestamp? -- NULL = unprocessed + created_at: Timestamp +} + +surface ProjectRecordSurface { + context project: Project + + exposes: + project.id + project.name + project.slug + project.description when project.description != null + project.data_path when project.data_path != null + project.created_at + project.updated_at + project.is_active +} + +surface PostTranslationRecordSurface { + context translation: PostTranslation + + exposes: + translation.id + translation.project_id + translation.translation_for + translation.language + translation.title + translation.excerpt when translation.excerpt != null + translation.content when translation.content != null + translation.status + translation.created_at + translation.updated_at + translation.published_at when translation.published_at != null + translation.file_path + translation.checksum when translation.checksum != null +} + +surface MediaTranslationRecordSurface { + context translation: MediaTranslation + + exposes: + translation.id + translation.project_id + translation.translation_for + translation.language + translation.title when translation.title != null + translation.alt when translation.alt != null + translation.caption when translation.caption != null + translation.created_at + translation.updated_at +} + +surface TagRecordSurface { + context tag: Tag + + exposes: + tag.id + tag.project_id + tag.name + tag.color when tag.color != null + tag.post_template_slug when tag.post_template_slug != null + tag.created_at + tag.updated_at +} + +surface TemplateRecordSurface { + context template: Template + + exposes: + template.id + template.project_id + template.slug + template.title + template.kind + template.enabled + template.version + template.file_path + template.status + template.content when template.content != null + template.created_at + template.updated_at +} + +surface ScriptRecordSurface { + context script: Script + + exposes: + script.id + script.project_id + script.slug + script.title + script.kind + script.entrypoint + script.enabled + script.version + script.file_path + script.status + script.content when script.content != null + script.created_at + script.updated_at +} + +surface PostLinkRecordSurface { + context link: PostLink + + exposes: + link.id + link.source_post_id + link.target_post_id + link.link_text when link.link_text != null + link.created_at +} + +surface PostMediaLinkRecordSurface { + context link: PostMediaLink + + exposes: + link.id + link.project_id + link.post_id + link.media_id + link.sort_order + link.created_at +} + +surface SettingRecordSurface { + context setting: Setting + + exposes: + setting.key + setting.value + setting.updated_at +} + +surface GeneratedFileHashRecordSurface { + context record: GeneratedFileHash + + exposes: + record.project_id + record.relative_path + record.content_hash + record.updated_at +} + +surface PostSearchIndexRecordSurface { + context record: PostSearchIndex + + exposes: + record.post + record.stemmed_content +} + +surface MediaSearchIndexRecordSurface { + context record: MediaSearchIndex + + exposes: + record.media + record.stemmed_content +} + +surface ChatConversationRecordSurface { + context conversation: ChatConversation + + exposes: + conversation.id + conversation.title + conversation.model when conversation.model != null + conversation.copilot_session_id when conversation.copilot_session_id != null + conversation.created_at + conversation.updated_at +} + +surface ChatMessageRecordSurface { + context message: ChatMessage + + exposes: + message.id + message.conversation_id + message.role + message.content when message.content != null + message.tool_call_id when message.tool_call_id != null + message.tool_calls when message.tool_calls != null + message.created_at +} + +surface AiModelRecordSurface { + context model: AiModel + + exposes: + model.provider + model.model_id + model.name + model.family when model.family != null + model.attachment + model.reasoning + model.tool_call + model.structured_output + model.temperature + model.knowledge when model.knowledge != null + model.release_date when model.release_date != null + model.last_updated_date when model.last_updated_date != null + model.open_weights + model.input_price when model.input_price != null + model.output_price when model.output_price != null + model.cache_read_price when model.cache_read_price != null + model.cache_write_price when model.cache_write_price != null + model.context_window + model.max_input_tokens + model.max_output_tokens + model.interleaved when model.interleaved != null + model.status when model.status != null + model.provider_package_ref when model.provider_package_ref != null + model.updated_at +} + +surface AiModelModalityRecordSurface { + context modality: AiModelModality + + exposes: + modality.provider + modality.model_id + modality.direction + modality.modality +} + +surface AiCatalogMetaRecordSurface { + context meta: AiCatalogMeta + + exposes: + meta.key + meta.value +} + +surface EmbeddingKeyRecordSurface { + context key: EmbeddingKey + + exposes: + key.label + key.post_id + key.project_id + key.content_hash + key.vector +} + +surface DismissedDuplicatePairRecordSurface { + context pair: DismissedDuplicatePair + + exposes: + pair.id + pair.project_id + pair.post_id_a + pair.post_id_b + pair.dismissed_at +} + +surface ImportDefinitionRecordSurface { + context definition: ImportDefinition + + exposes: + definition.id + definition.project_id + definition.name + definition.wxr_file_path when definition.wxr_file_path != null + definition.uploads_folder_path when definition.uploads_folder_path != null + definition.last_analysis_result when definition.last_analysis_result != null + definition.created_at + definition.updated_at +} + +surface DbNotificationRecordSurface { + context notification: DbNotification + + exposes: + notification.id + notification.entity_type + notification.entity_id + notification.action + notification.from_cli + notification.seen_at when notification.seen_at != null + notification.created_at +} + +surface Fts5PostSchemaSurface { + context schema: Fts5PostSchema + + exposes: + schema.fields + schema.stemmer_languages +} + +surface Fts5MediaSchemaSurface { + context schema: Fts5MediaSchema + + exposes: + schema.fields + schema.stemmer_languages +} + +surface MigrationVersionSurface { + context _: MigrationVersion +} + +-- ============================================================================ +-- SCHEMA CONSTRAINTS AND INDEXES +-- ============================================================================ + +invariant UniqueProjectSlug { + -- projects.slug must be unique across all projects +} + +invariant UniquePostSlugPerProject { + -- posts.slug must be unique within each project.project_id + -- Enforced by: posts_project_slug_idx unique index +} + +invariant UniqueTranslationPerPostLanguage { + -- post_translations must have unique (translation_for, language) + -- Enforced by: post_translations_translation_language_idx +} + +invariant UniqueMediaTranslationPerMediaLanguage { + -- media_translations must have unique (translation_for, language) + -- Enforced by: media_translations_translation_language_idx +} + +invariant UniqueTagNamePerProject { + -- tags.name must be unique within each project.project_id + -- Enforced by: tags_project_name_idx unique index +} + +invariant UniqueScriptSlugPerProject { + -- scripts.slug must be unique within each project.project_id + -- Enforced by: scripts_project_slug_idx unique index +} + +invariant UniqueTemplateSlugPerProject { + -- templates.slug must be unique within each project.project_id + -- Enforced by: templates_project_slug_idx unique index +} + +invariant UniquePostMediaLink { + -- post_media must have unique (post_id, media_id) pair + -- Enforced by: post_media_post_media_idx unique index +} + +invariant UniqueGeneratedFileHash { + -- generated_file_hashes must have unique (project_id, relative_path) + -- Enforced by: generated_file_hashes_project_path_idx unique index +} + +invariant UniqueDismissedDuplicatePair { + -- dismissed_duplicate_pairs must have unique (project_id, post_id_a, post_id_b) + -- Enforced by: dismissed_pairs_idx unique index +} + +-- ============================================================================ +-- FTS5 VIRTUAL TABLE SCHEMAS (Snowball Stemmer Integration) +-- ============================================================================ + +value Fts5PostSchema { + -- CREATE VIRTUAL TABLE posts_fts USING fts5( + -- post_id UNINDEXED, + -- title, excerpt, content, tags, categories + -- ); + -- Standalone table (no content-sync) because text is pre-stemmed + -- via Snowball before insertion; content-sync would read un-stemmed + -- base-table text at query time instead. + fields: Set -- {post_id UNINDEXED, title, excerpt, content, tags, categories} + stemmer_languages: Integer = 24 +} + +value Fts5MediaSchema { + -- CREATE VIRTUAL TABLE media_fts USING fts5( + -- media_id UNINDEXED, + -- title, alt, caption, original_name, tags + -- ); + -- Standalone table (no content-sync) — same rationale as posts_fts. + fields: Set -- {media_id UNINDEXED, title, alt, caption, original_name, tags} + stemmer_languages: Integer = 24 +} + +-- ============================================================================ +-- MIGRATION HISTORY +-- ============================================================================ + +value MigrationVersion { + -- Schema version tracking via refinery migrations + -- Current version: 0007 (scripts and templates draft lifecycle) + -- Migration files located in: migrations/ + -- Note: Migration list documented in comments, not as Allium value +} diff --git a/specs/script.allium b/specs/script.allium new file mode 100644 index 0000000..795a229 --- /dev/null +++ b/specs/script.allium @@ -0,0 +1,188 @@ +-- allium: 1 +-- bDS Scripting System +-- Scope: core (Wave 6 — scripting behaviour and file contracts) +-- Distilled from: src/main/engine/ScriptEngine.ts, schema.ts +-- The scripting runtime is intentionally unspecified here; only behavioural +-- contracts are normative. + +config { + script_extension: String = "script" +} + +entity Script { + slug: String + title: String + kind: macro | utility | transform + entrypoint: String -- default: "render" for macros + enabled: Boolean + status: draft | published + content: String? + version: Integer + file_path: String + created_at: Timestamp + updated_at: Timestamp + + -- Derived + content_location: if status = published: file_path else: content + + transitions status { + draft -> published + published -> draft + } +} + +surface ScriptSurface { + context script: Script + + exposes: + script.slug + script.title + script.kind + script.entrypoint + script.enabled + script.status + script.content when script.content != null + script.version + script.file_path + script.created_at + script.updated_at + script.content_location +} + +surface ScriptManagementSurface { + facing _: ScriptOperator + + provides: + CreateScriptRequested(title, kind, content, entrypoint) + CreateAndPublishScriptRequested(title, kind, content, entrypoint) + UpdateScriptRequested(script, changes) + PublishScriptRequested(script) + DeleteScriptRequested(script) + RunUtilityRequested(script) + MacroExpansionRequested(script, template_context) + BlogmarkReceived(data) + RebuildScriptsFromFilesRequested(project) +} + +invariant UniqueScriptSlug { + for a in Scripts: + for b in Scripts: + a != b implies a.slug != b.slug +} + +invariant ScriptFileLayout { + for s in Scripts where file_path != "": + s.file_path = format("scripts/{slug}.{extension}", slug: s.slug, extension: config.script_extension) +} +-- Script files use standard --- YAML frontmatter + +rule CreateScript { + when: CreateScriptRequested(title, kind, content, entrypoint) + let slug = slugify(title) + -- Creates a draft script: content stored in DB, no file written yet + ensures: + let new_script = Script.created( + slug: slug, + title: title, + kind: kind, + content: content, + entrypoint: entrypoint ?? "render", + status: draft, + enabled: true, + version: 1, + file_path: "" + ) + new_script.status = draft +} + +rule UpdateScript { + when: UpdateScriptRequested(script, changes) + ensures: ScriptFieldsUpdated(script, changes) + ensures: script.updated_at = now + ensures: script.version = script.version + 1 +} + +rule ReopenPublishedScript { + when: UpdateScriptRequested(script, changes) + requires: script.status = published + requires: script_changes_affect_published_output(changes) + ensures: script.status = draft +} + +rule CreateAndPublishScript { + -- Alternative creation path: create + immediately publish (file written) + -- Some implementations may expose this as a single user action + when: CreateAndPublishScriptRequested(title, kind, content, entrypoint) + let slug = slugify(title) + requires: ValidateScript(content) = valid + ensures: + let new_script = Script.created( + slug: slug, + title: title, + kind: kind, + content: null, + entrypoint: entrypoint ?? "render", + status: published, + enabled: true, + version: 1, + file_path: format("scripts/{slug}.{extension}", slug: slug, extension: config.script_extension) + ) + ScriptFileWritten(new_script) +} + +rule PublishScript { + when: PublishScriptRequested(script) + requires: script.status = draft + requires: ValidateScript(script.content) = valid + -- AST parsing must succeed + ensures: script.status = published + ensures: ScriptFileWritten(script) + ensures: script.content = null +} + +rule DeleteScript { + when: DeleteScriptRequested(script) + ensures: not exists script + ensures: ScriptFileDeleted(script) +} + +-- Script execution contracts by kind + +rule ExecuteMacro { + when: MacroExpansionRequested(script, template_context) + requires: script.kind = macro + requires: script.enabled = true + -- Macro scripts are invoked during template rendering + -- via [[slug param1=value1 param2=value2]] syntax in post content + -- They receive named parameters and the template context, return HTML + ensures: MacroOutputProduced(script, html_output) +} + +rule ExecuteUtility { + when: RunUtilityRequested(script) + requires: script.kind = utility + requires: script.enabled = true + -- Runs on-demand from the UI, produces stdout output + ensures: UtilityOutputProduced(script, stdout) +} + +rule ExecuteTransform { + when: BlogmarkReceived(data) + -- Transform scripts run sequentially on blogmark deep link data + -- Input: title, content, tags, categories, source url + -- Each transform can modify the data before post creation + let transforms = Scripts where kind = transform and enabled = true + for t in ordered_by(transforms, s => s.slug): + ensures: TransformApplied(t, data) + + @guidance + -- bds://new-post deep links from browser bookmarks + -- Max 5 toast notifications per script, 20 total +} + +rule RebuildScriptsFromFiles { + when: RebuildScriptsFromFilesRequested(project) + for file in scan_directory(project.effective_data_dir + "/scripts", "*." + config.script_extension): + let parsed = parse_script_file(file) + ensures: Script.created(parsed) +} diff --git a/specs/search.allium b/specs/search.allium new file mode 100644 index 0000000..ba4f4e1 --- /dev/null +++ b/specs/search.allium @@ -0,0 +1,118 @@ +-- allium: 1 +-- bDS Full-Text Search +-- Scope: core (Wave 1 — in-app full-text search with Snowball stemmers) +-- Distilled from: src/main/engine/PostEngine.ts (FTS methods), +-- MediaEngine.ts (FTS methods), stemmer.ts + +use "./post.allium" as post +use "./media.allium" as media + +surface SearchControlSurface { + facing _: SearchOperator + + provides: + SearchPostsRequested(query, filters) + SearchMediaRequested(query) +} + +surface SearchIndexRuntimeSurface { + facing _: SearchRuntime + + provides: + SearchIndexUpdated(post) + SearchIndexUpdated(media) +} + +value StemmerLanguage { + -- Snowball stemmers for 24 languages + -- ISO 639-1 to Snowball mapping + -- Applied to both indexing and query processing + code: String +} + +surface StemmerLanguageSurface { + context language: StemmerLanguage + + exposes: + language.code +} + +entity PostSearchIndex { + -- Full-text index projection + -- Indexed fields: title, excerpt, content, tags, categories + -- Plus all translation titles, excerpts, and content + post: post/Post + stemmed_content: String +} + +entity MediaSearchIndex { + -- Full-text index projection + -- Indexed fields: title, alt, caption, original_name, tags + -- Plus all translation titles, alts, and captions + media: media/Media + stemmed_content: String +} + +invariant CrossLanguageStemming { + -- Search index uses Snowball stemmer matched to content language + -- A post in German is stemmed with the German stemmer + -- Translations are stemmed with their respective language stemmers + -- Query-time stemming matches the index language +} + +rule SearchPosts { + when: SearchPostsRequested(query, filters) + -- Full-text search with optional filters: + -- status, tags, categories, language, missingTranslationLanguage, + -- year, month, date range (from/to) + -- Returns paginated results with total count + let stemmed_query = stem(query, detect_language(query)) + let matched = search_fts(PostSearchIndex, stemmed_query, filters) + ensures: SearchResults( + posts: matched, + total: matched.count, + offset: filters.offset, + limit: filters.limit + ) +} + +rule SearchMedia { + when: SearchMediaRequested(query) + let stemmed_query = stem(query, detect_language(query)) + let matched = search_fts(MediaSearchIndex, stemmed_query) + ensures: SearchResults( + media: matched + ) +} + +rule IndexPost { + when: SearchIndexUpdated(post) + -- Stems: title + excerpt + content + tags + categories + -- Plus all translations' title + excerpt + content + let all_text = concat_post_text(post) + -- Concatenates: post.title, post.excerpt, post.content, + -- join(post.tags, " "), join(post.categories, " "), + -- and all translations' title, excerpt, content + let index_entry = PostSearchIndex{post: post} + ensures: + if exists index_entry: + index_entry.stemmed_content = stem(all_text) + else: + PostSearchIndex.created(post: post, stemmed_content: stem(all_text)) +} + +rule IndexMedia { + when: SearchIndexUpdated(media) + -- Stems: title + alt + caption + original_name + tags + -- Plus all translations' title, alt, caption + let all_text = concat_media_text(media) + -- Concatenates: media.title, media.alt, media.caption, + -- media.original_name, join(media.tags, " "), + -- and all translations' title, alt, caption + let index_entry = MediaSearchIndex{media: media} + ensures: + if exists index_entry: + index_entry.stemmed_content = stem(all_text) + else: + MediaSearchIndex.created(media: media, stemmed_content: stem(all_text)) +} diff --git a/specs/sidebar_views.allium b/specs/sidebar_views.allium new file mode 100644 index 0000000..5805063 --- /dev/null +++ b/specs/sidebar_views.allium @@ -0,0 +1,799 @@ +-- allium: 1 +-- bDS Sidebar Views +-- Scope: UI content (all waves + extensions) +-- Distilled from: Sidebar.tsx, PostsList.tsx, MediaList.tsx, +-- SidebarEntityList.tsx, SettingsNav.tsx, TagsNav.tsx, +-- ChatList.tsx, ImportList.tsx, ScriptsList.tsx, TemplatesList.tsx, +-- GitSidebar.tsx, sidebarDateFormatting.ts + +-- Describes the content and behaviour of each of the 10 sidebar views. +-- The sidebar shell (visibility, resize, view switching) is in layout.allium. +-- Tab opening behaviour is in tabs.allium. + +use "./layout.allium" as layout +use "./tabs.allium" as tabs +use "./post.allium" as post +use "./media.allium" as media +use "./tag.allium" as tag +use "./i18n.allium" as i18n + +-- ─── Sidebar view registry ─────────────────────────────────── + +-- 10 views: posts, pages, media, scripts, templates, settings, tags, chat, import, git +-- Default view: posts +-- The sidebar renders exactly one view at a time, selected by active_view. +-- Each ActivityId maps 1:1 to a SidebarView of the same name. + +config { + default_sidebar_view: String = "posts" +} + +-- ─── Shared patterns ───────────────────────────────────────── + +value LocaleMapping { + ui_locale: String -- en | de | fr | it | es + format_locale: String -- en-US | de-DE | fr-FR | it-IT | es-ES +} + +default LocaleMapping en_locale = { ui_locale: "en", format_locale: "en-US" } +default LocaleMapping de_locale = { ui_locale: "de", format_locale: "de-DE" } +default LocaleMapping fr_locale = { ui_locale: "fr", format_locale: "fr-FR" } +default LocaleMapping it_locale = { ui_locale: "it", format_locale: "it-IT" } +default LocaleMapping es_locale = { ui_locale: "es", format_locale: "es-ES" } + +config { + fallback_format_locale: String = "en-US" +} + +value RelativeDateFormat { + timestamp: Timestamp + locale: String + diff_days: Integer -- (today - timestamp.date).days + + -- Derived + display: String = + if diff_days = 0: timestamp.toLocaleTimeString(locale) + else if diff_days = 1: i18n/translate("sidebar.chat.yesterday", locale) + else if diff_days < 7: timestamp.toLocaleDateString(locale, weekday: short) + else: timestamp.toLocaleDateString(locale, month: short, day: numeric) +} + +surface RelativeDateFormatSurface { + context format: RelativeDateFormat + + exposes: + format.timestamp + format.locale + format.diff_days + format.display +} + +value PostDateFormat { + timestamp: Timestamp + locale: String + + -- Derived + display: String = timestamp.toLocaleDateString(locale, month: short, day: numeric, year: numeric) + -- Example: "Feb 10, 2026" +} + +surface PostDateFormatSurface { + context format: PostDateFormat + + exposes: + format.timestamp + format.locale + format.display +} + +value PostTypeIcon { + categories: List + + -- Derived: first category match wins, case-insensitive + icon: String = + if categories.any(c => lowercase(c) in {"picture", "photo", "image"}): "camera" + else if categories.any(c => lowercase(c) in {"aside", "note", "quick"}): "notepad" + else if categories.any(c => lowercase(c) in {"link", "bookmark"}): "link" + else if categories.any(c => lowercase(c) = "video"): "film" + else if categories.any(c => lowercase(c) = "quote"): "speech_bubble" + else: "document" +} + +surface PostTypeIconSurface { + context icon: PostTypeIcon + + exposes: + icon.categories + icon.icon +} + +invariant SidebarEntityListPattern { + -- Views following this pattern (scripts, templates, chat, import) must provide: + -- 1. Header with localised title and create button. + -- 2. Scrollable list of items. + -- 3. Empty state with localised message and action call-to-action when items list is empty. + -- 4. All text in list items uses CSS text-overflow:ellipsis on sidebar width overflow. +} + +-- ─── 1. Posts view ──────────────────────────────────────────── + +value PostsView { + mode: String -- "posts" or "pages" + search_query: String? -- FTS via posts.search(query) + filter_panel_visible: Boolean -- collapsible, toggled by icon button + calendar_filter: CalendarFilter? -- year/month archive tree + tag_filter: List -- selected tags (multi-select chips with colours) + category_filter: List -- selected categories (multi-select chips) + draft_section: List + published_section: List + archived_section: List + has_more: Boolean -- pagination, 500 per batch +} + +surface PostsViewSurface { + context view: PostsView + + exposes: + view.mode + view.search_query + view.filter_panel_visible + view.calendar_filter when view.calendar_filter != null + view.tag_filter + view.category_filter + view.draft_section.count + view.published_section.count + view.archived_section.count + view.has_more +} + +-- Drafts section always shows all drafts regardless of filters. +-- Published and archived sections respect active filters. +-- "Clear All Filters" button resets search, date, tags, categories. +-- Filters auto-refresh when any post's status changes. + +value PostListItem { + post_id: String + type_icon: String -- derived via PostTypeIcon from source post categories + title: String -- post.title, fallback "Untitled" + language_count: Integer? -- shown when availableLanguages.count > 1 + date: String -- locale-formatted via PostDateFormat + active: Boolean -- true when activeTabId = post.id +} + +surface PostListItemEntry { + context item: PostListItem + + exposes: + item.type_icon + item.title + item.language_count when item.language_count != null + item.date + item.active + + provides: + PostListItemClicked(item.post_id, single) + PostListItemClicked(item.post_id, double) + + @guarantee RowLayout + -- Row with two columns. + -- Left column: type_icon (fixed width, top-aligned). + -- Right column, line 1: title (fills available width, truncated with ellipsis) + -- and language_count badge (right-aligned pill, smaller font) when present. + -- Right column, line 2: date (smaller, muted colour). + + @guarantee ActiveIndicator + -- When item.active is true, entry shows a coloured left-border accent. + + @guarantee PostTypeBackground + -- Row has a subtle background tint derived from the type_icon category. + + @guarantee DateSource + -- For published posts: date derives from publishedAt, falling back to updatedAt. + -- For draft and archived posts: date derives from updatedAt. +} + +rule PostListClick { + when: PostListItemClicked(post_id, click_type) + if click_type = single: + ensures: OpenTabRequested(type: post, id: post_id, intent: preview) + if click_type = double: + ensures: OpenTabRequested(type: post, id: post_id, intent: pin) +} + +-- ─── 2. Pages view ─────────────────────────────────────────── + +-- Identical to PostsView but: +-- mode = "pages" +-- Filters to posts with "page" category +-- Create button auto-adds "page" category +-- Category filter excludes "page" from chips but auto-merges into backend calls + +-- ─── 3. Media view ──────────────────────────────────────────── + +value MediaView { + search_query: String? -- FTS via media.search(query) + filter_panel_visible: Boolean + calendar_filter: CalendarFilter? + tag_filter: List -- tags only, no categories for media + grid: List -- grid layout (not list) +} + +surface MediaViewSurface { + context view: MediaView + + exposes: + view.search_query + view.filter_panel_visible + view.calendar_filter when view.calendar_filter != null + view.tag_filter + view.grid.count +} + +value MediaGridItem { + media_id: String + thumbnail_path: String? -- small (150px) thumbnail on disk when image; null for non-image + name: String -- title truncated to config.media_title_max_length + "..."; fallback originalName (no truncation) + file_size: String -- formatted (B / KB / MB) + dimensions: String? -- "WxH" when width and height known; null otherwise + tooltip: String -- caption ?? originalName + active: Boolean -- true when activeTabId = media.id +} + +surface MediaGridItemEntry { + context item: MediaGridItem + + exposes: + item.thumbnail_path when item.thumbnail_path != null + item.name + item.file_size + item.dimensions when item.dimensions != null + item.tooltip + item.active + + provides: + MediaGridItemClicked(item.media_id, single) + MediaGridItemClicked(item.media_id, double) + + @guarantee CellLayout + -- Grid cell, row layout. + -- Left: 40x40 thumbnail (rounded, object-fit cover) loaded from + -- thumbnail_path (small 150px WebP) when present; + -- otherwise generic file icon of same dimensions. + -- Right column, line 1: name (truncated with ellipsis). + -- Right column, line 2: file_size (smaller, muted) followed by + -- dimensions when present, separated by " · ". + + @guarantee NameTruncation + -- Title is hard-truncated at config.media_title_max_length characters + -- with "..." suffix appended. originalName is never hard-truncated. + -- CSS text-overflow:ellipsis applies as additional safety net on both. + + @guarantee TooltipContent + -- Tooltip shows caption when available, otherwise originalName. +} + +config { + media_title_max_length: Integer = 60 +} + +rule MediaListClick { + when: MediaGridItemClicked(media_id, click_type) + if click_type = single: + ensures: OpenTabRequested(type: media, id: media_id, intent: preview) + if click_type = double: + ensures: OpenTabRequested(type: media, id: media_id, intent: pin) +} + +-- Import button: opens native file import dialog + +-- ─── 4. Scripts view ────────────────────────────────────────── + +-- Follows SidebarEntityListPattern. + +value ScriptsView { + items: List +} + +surface ScriptsViewSurface { + context view: ScriptsView + + exposes: + view.items.count +} + +value ScriptListItem { + script_id: String + title: String -- truncated with ellipsis on overflow + date: String -- relative date format via RelativeDateFormat + active: Boolean +} + +surface ScriptListItemEntry { + context item: ScriptListItem + + exposes: + item.title + item.date + item.active + + provides: + ScriptListItemClicked(item.script_id, single) + ScriptListItemClicked(item.script_id, double) + ScriptDeleteRequested(item.script_id) + + @guarantee RowLayout + -- Row with two areas. + -- Left area (fills width), two lines: + -- Line 1: title (truncated with ellipsis). + -- Line 2: date in relative format (smaller, muted). + -- Right area: delete button (x), visible only on row hover, turns red on hover. + + @guarantee KeyboardNavigation + -- Enter key: pin (opens pinned tab). + -- Space key: preview (opens transient tab). + -- Row is focusable with tabIndex and has aria-label from title. + + @guarantee DeleteBehaviour + -- Delete removes the script and closes its open tab if any. + + @guarantee CreateDefaults + -- New scripts default to: kind=utility, content='print("new script")', + -- entrypoint='render', enabled=true. + + @guarantee LiveRefresh + -- Item list refreshes on scripts-changed event. +} + +rule ScriptListClick { + when: ScriptListItemClicked(script_id, click_type) + if click_type = single: + ensures: OpenTabRequested(type: scripts, id: script_id, intent: preview) + if click_type = double: + ensures: OpenTabRequested(type: scripts, id: script_id, intent: pin) +} + +-- ─── 5. Templates view ─────────────────────────────────────── + +-- Follows SidebarEntityListPattern. Same item layout as ScriptListItemEntry. + +value TemplatesView { + items: List +} + +surface TemplatesViewSurface { + context view: TemplatesView + + exposes: + view.items.count +} + +value TemplateListItem { + template_id: String + title: String -- truncated with ellipsis on overflow + date: String -- relative date format via RelativeDateFormat + active: Boolean +} + +surface TemplateListItemEntry { + context item: TemplateListItem + + exposes: + item.title + item.date + item.active + + provides: + TemplateListItemClicked(item.template_id, single) + TemplateListItemClicked(item.template_id, double) + TemplateDeleteRequested(item.template_id) + + @guarantee RowLayout + -- Row with two areas. + -- Left area (fills width), two lines: + -- Line 1: title (truncated with ellipsis). + -- Line 2: date in relative format (smaller, muted). + -- Right area: delete button (x), visible only on row hover, turns red on hover. + + @guarantee KeyboardNavigation + -- Enter key: pin (opens pinned tab). + -- Space key: preview (opens transient tab). + -- Row is focusable with tabIndex and has aria-label from title. + + @guarantee DeleteConfirmation + -- If template is referenced by posts or tags, shows confirmation dialog + -- with reference counts before force-delete. + + @guarantee CreateDefaults + -- New templates default to: kind=post, content='', enabled=true. + + @guarantee LiveRefresh + -- Item list refreshes on templates-changed event. +} + +rule TemplateListClick { + when: TemplateListItemClicked(template_id, click_type) + if click_type = single: + ensures: OpenTabRequested(type: templates, id: template_id, intent: preview) + if click_type = double: + ensures: OpenTabRequested(type: templates, id: template_id, intent: pin) +} + +-- ─── 6. Settings view ──────────────────────────────────────── + +-- Navigation list that controls sections within the settings editor tab. + +value SettingsNavEntry { + section: String + icon: String + label_key: String + active: Boolean +} + +invariant SettingsNavSections { + -- Settings navigation has exactly 9 entries in this fixed order: + -- 1. section="project", icon="folder", label_key="settings.nav.project" + -- 2. section="editor", icon="notepad", label_key="settings.nav.editor" + -- 3. section="content", icon="clipboard", label_key="settings.nav.content" + -- 4. section="ai", icon="robot", label_key="settings.nav.ai" + -- 5. section="technology", icon="gear", label_key="settings.nav.technology" + -- 6. section="publishing", icon="rocket", label_key="settings.nav.publishing" + -- 7. section="data", icon="database", label_key="settings.nav.data" + -- 8. section="mcp", icon="plug", label_key="settings.nav.mcp" + -- 9. section="style", icon="palette", label_key="settings.nav.style" + -- Labels are localised via their label_key through i18n. +} + +surface SettingsNavEntryView { + context entry: SettingsNavEntry + + exposes: + entry.icon + entry.label_key + entry.active + + provides: + SettingsNavEntryClicked(entry.section) + + @guarantee RowLayout + -- Row with icon (fixed 20px width, centred) and localised text label. + -- Active entry has distinct selection background and foreground colours. + + @guarantee FixedOrder + -- Entries always appear in the order defined by SettingsNavSections invariant. +} + +value SettingsNav { + active_section: String? -- persisted across sidebar switches +} + +surface SettingsNavSurface { + context nav: SettingsNav + + exposes: + nav.active_section when nav.active_section != null +} + +rule SettingsNavClick { + when: SettingsNavEntryClicked(section) + if section = style: + ensures: OpenTabRequested(type: style, id: style, intent: pin) + else: + ensures: OpenTabRequested(type: settings, id: settings, intent: pin) + ensures: ScrollToSection(section) + ensures: active_section = section + -- Active section is persisted across sidebar switches +} + +-- ─── 7. Tags view ───────────────────────────────────────────── + +-- Navigation list that controls sections within the tags editor tab. + +value TagsNavEntry { + section: String + icon: String + label_key: String + active: Boolean +} + +invariant TagsNavSections { + -- Tags navigation has exactly 3 entries in this fixed order: + -- 1. section="cloud", icon="cloud", label_key="tags.nav.cloud" -- tag cloud visualisation + -- 2. section="manage", icon="pencil", label_key="tags.nav.manage" -- create/edit tags + -- 3. section="merge", icon="merge", label_key="tags.nav.merge" -- merge duplicate tags + -- Labels are localised via their label_key through i18n. +} + +surface TagsNavEntryView { + context entry: TagsNavEntry + + exposes: + entry.icon + entry.label_key + entry.active + + provides: + TagsNavEntryClicked(entry.section) + + @guarantee RowLayout + -- Row with icon (fixed 20px width, centred) and localised text label. + -- Active entry has distinct selection background and foreground colours. + -- Same visual structure as SettingsNavEntryView. + + @guarantee FixedOrder + -- Entries always appear in the order defined by TagsNavSections invariant. +} + +value TagsNav { + active_section: String? -- persisted +} + +surface TagsNavSurface { + context nav: TagsNav + + exposes: + nav.active_section when nav.active_section != null +} + +rule TagsNavClick { + when: TagsNavEntryClicked(section) + ensures: OpenTabRequested(type: tags, id: tags, intent: pin) + ensures: ScrollToSection(section) + ensures: active_section = section + -- Active section is persisted across sidebar switches +} + +-- ─── 8. Chat view ───────────────────────────────────────────── + +-- Follows SidebarEntityListPattern. + +value ChatView { + api_ready: Boolean -- shows API key prompt if false + items: List +} + +surface ChatViewSurface { + context view: ChatView + + exposes: + view.api_ready + view.items.count +} + +value ChatListItem { + conversation_id: String + title: String -- live-updated via onTitleUpdated + date: String -- relative date format via RelativeDateFormat +} + +surface ChatListItemEntry { + context item: ChatListItem + + exposes: + item.title + item.date + + provides: + ChatListItemClicked(item.conversation_id) + ChatDeleteRequested(item.conversation_id) + + @guarantee RowLayout + -- Row with two areas. + -- Left area (fills width), two lines: + -- Line 1: title (truncated with ellipsis, live-updated). + -- Line 2: date in relative format (smaller, muted). + -- Right area: delete button (x), visible only on row hover. + + @guarantee DeleteBehaviour + -- Delete removes the conversation and closes its open tab if any. + + @guarantee AlwaysPinned + -- Chat tabs are always opened as pinned (never transient). +} + +rule ChatListClick { + when: ChatListItemClicked(conversation_id) + ensures: OpenTabRequested(type: chat, id: conversation_id, intent: pin) + -- Chat tabs are always pinned +} + +-- ─── 9. Import view ────────────────────────────────────────── + +-- Follows SidebarEntityListPattern. + +value ImportView { + items: List +} + +surface ImportViewSurface { + context view: ImportView + + exposes: + view.items.count +} + +value ImportListItem { + definition_id: String + name: String -- live-updated via onNameUpdated + date: String -- relative date format via RelativeDateFormat +} + +surface ImportListItemEntry { + context item: ImportListItem + + exposes: + item.name + item.date + + provides: + ImportListItemClicked(item.definition_id) + ImportDeleteRequested(item.definition_id) + + @guarantee RowLayout + -- Row with two areas. + -- Left area (fills width), two lines: + -- Line 1: name (truncated with ellipsis, live-updated). + -- Line 2: date in relative format (smaller, muted). + -- Right area: delete button (x), visible only on row hover. + + @guarantee DeleteBehaviour + -- Delete removes the import definition and closes its open tab if any. + + @guarantee AlwaysPinned + -- Import tabs are always opened as pinned (never transient). +} + +rule ImportListClick { + when: ImportListItemClicked(definition_id) + ensures: OpenTabRequested(type: import, id: definition_id, intent: pin) + -- Import tabs are always pinned +} + +-- ─── 10. Git view ───────────────────────────────────────────── + +-- Full git interface, not the SidebarEntityList pattern. +-- Three possible states: loading, not_a_repo, active_repo. + +-- State: not_a_repo +-- Remote URL text input + "Initialize Git" button. +-- Init progress with phase/percentage/detail, collapsible transcript. + +-- State: active_repo +value GitActiveView { + branch: String -- current branch name + upstream: String? -- tracking info (local -> upstream) + ahead: Integer + behind: Integer + status_files: List + history_entries: List + has_more_history: Boolean -- paginated, 20 per page +} + +surface GitActiveViewSurface { + context view: GitActiveView + + exposes: + view.branch + view.upstream when view.upstream != null + view.ahead + view.behind + view.status_files.count + view.history_entries.count + view.has_more_history + + provides: + GitCommitRequested(message) +} + +value GitStatusFile { + path: String + status: String -- modified, added, deleted, renamed, etc. +} + +surface GitStatusFileEntry { + context file: GitStatusFile + + exposes: + file.path + file.status + + provides: + GitStatusFileClicked(file.path, single) + GitStatusFileClicked(file.path, double) + + @guarantee RowLayout + -- Row with two elements, justified space-between. + -- Left: file path (truncated with ellipsis, fills available width). + -- Right: status badge (short uppercase code e.g. "M", "A", "D"; muted colour, fixed width). + + @guarantee Tooltip + -- Tooltip shows "status: path" (e.g. "modified: src/main.rs"). +} + +value GitHistoryEntry { + short_hash: String -- 7 chars + subject: String -- wraps (word-break), not truncated + author: String + date: String -- locale-formatted + sync_status: String -- synced, local_only, remote_only +} + +surface GitHistoryEntryView { + context entry: GitHistoryEntry + + exposes: + entry.short_hash + entry.subject + entry.author + entry.date + entry.sync_status + + provides: + GitHistoryEntryClicked(entry.short_hash, single) + GitHistoryEntryClicked(entry.short_hash, double) + + @guarantee EntryLayout + -- Two lines. + -- Line 1: subject (wraps with word-break, never truncated). + -- Line 2: short_hash + author + date + sync_status indicator, separated by spacing. + + @guarantee SyncStatusIndicator + -- sync_status rendered as a coloured dot. + -- "synced" = both local and remote. "local_only" = local only. "remote_only" = remote only. + -- A colour legend is shown in the git view header. +} + +-- Action buttons: fetch, pull, push, prune_lfs. All disabled while any action is loading. +-- Changes section: file count, commit message input, commit button, file list. +-- Changes list polls every 2 seconds in background. +-- Remote state refreshes every 30 seconds with auto-fetch. + +rule GitFileClick { + when: GitStatusFileClicked(file_path, click_type) + if click_type = single: + ensures: OpenTabRequested(type: git_diff, id: "git-diff:" + file_path, intent: preview) + if click_type = double: + ensures: OpenTabRequested(type: git_diff, id: "git-diff:" + file_path, intent: pin) +} + +rule GitHistoryClick { + when: GitHistoryEntryClicked(commit_hash, click_type) + if click_type = single: + ensures: OpenTabRequested(type: git_diff, id: "git-diff:commit:" + commit_hash, intent: preview) + if click_type = double: + ensures: OpenTabRequested(type: git_diff, id: "git-diff:commit:" + commit_hash, intent: pin) +} + +rule GitCommit { + when: GitCommitRequested(message) + ensures: git.commitAll(message) + -- Also: all git_diff tabs are closed and git state is reloaded +} + +-- ─── Calendar archive (shared widget) ───────────────────────── + +-- Collapsible year/month tree. +-- Selecting a year loads all posts/media for that year. +-- Selecting a month narrows to that month. + +value CalendarFilter { + selected_year: Integer? + selected_month: Integer? -- 1-12 +} + +value CalendarYear { + year: Integer + months: List +} + +surface CalendarYearSurface { + context calendar_year: CalendarYear + + exposes: + calendar_year.year + calendar_year.months.count +} + +value CalendarMonth { + month: Integer -- 1-12 + count: Integer -- number of items in this month +} diff --git a/specs/tabs.allium b/specs/tabs.allium new file mode 100644 index 0000000..33941d4 --- /dev/null +++ b/specs/tabs.allium @@ -0,0 +1,247 @@ +-- allium: 1 +-- bDS Tab System and Editor Routing +-- Scope: UI navigation (all waves) +-- Distilled from: tabPolicy.ts, TabBar.tsx, editorRouting.ts, appStore.ts + +-- Governs the tab bar, tab lifecycle (open/close/pin), editor routing, +-- and the relationship between tabs and the content area. + +use "./layout.allium" as layout + +surface TabControlSurface { + facing _: TabOperator + + provides: + OpenTabRequested(type, id, intent) + OpenTabInBackgroundRequested(type, id, intent) + CloseTabRequested(tab) + PinTabRequested(tab) + ClearTabsRequested() +} + +surface TabRuntimeSurface { + facing _: TabRuntime + + provides: + TabOpening(tab_type, intent) + ActiveTabChanged(active_tab) +} + +-- ─── Tab types ──────────────────────────────────────────────── + +-- 17 distinct tab types, each routing to a matching editor view. +-- Plus "dashboard" as the no-tab default view. +-- +-- Tab types: post, media, settings, style, tags, chat, import, +-- menu_editor, metadata_diff, git_diff, documentation, +-- api_documentation, site_validation, translation_validation, +-- scripts, templates, find_duplicates +-- +-- Editor routes: all of the above plus "dashboard" (shown when no tab is active). +-- Route registry is 1:1: every tab type maps to itself as an editor route. + +-- ─── Tab entity ─────────────────────────────────────────────── + +value Tab { + type: String -- one of the 17 tab types + id: String -- singleton: id = type name; entity: external ID + is_transient: Boolean -- true = preview tab (italic title, replaceable) +} + +surface TabSurface { + context tab: Tab + + exposes: + tab.type + tab.id + tab.is_transient +} + +-- ─── Tab categories ─────────────────────────────────────────── + +-- 1. Singleton tool tabs: always one instance, never transient, id = type name. +-- settings, tags, style, scripts (bare), menu_editor, documentation, +-- api_documentation, metadata_diff, site_validation, +-- translation_validation, find_duplicates +-- Total: 11 singleton types. + +-- 2. Entity tabs: keyed by external ID, support preview/pin intent. +-- post (id = postId), media (id = mediaId) + +-- 3. Script tabs: type = scripts, id = scriptId (NOT the singleton). +-- Support preview/pin intent. + +-- 4. Template tabs: type = templates, id = templateId. +-- Support preview/pin intent. + +-- 5. Chat tabs: type = chat, id = conversationId. Always pinned (not transient). + +-- 6. Import tabs: type = import, id = definitionId. Always pinned. + +-- 7. Git diff tabs: type = git_diff. +-- File diff: id = "git-diff:{filePath}" +-- Commit diff: id = "git-diff:commit:{commitHash}" +-- Support preview/pin intent. + +-- ─── Open intent ────────────────────────────────────────────── + +-- preview: transient tab (replaced by next preview of same type) +-- pin: permanent tab (persists until explicitly closed) + +rule DeriveTransient { + when: TabOpening(tab_type, intent) + if tab_type in singleton_tool_tabs: + ensures: tab.is_transient = false + else if tab_type = chat or tab_type = import: + ensures: tab.is_transient = false + else: + ensures: tab.is_transient = (intent = preview) +} + +-- ─── Tab lifecycle ──────────────────────────────────────────── + +rule OpenTab { + when: OpenTabRequested(type, id, intent) + + -- Dedup: if tab with same (type, id) already exists, activate it. + -- If intent = pin, also set is_transient = false. + -- Transient replacement: if opening as transient and a transient tab + -- of same type exists, replace it with the new tab. + -- Otherwise: append a new tab. + -- Always sets active_tab to the opened/reused tab. + ensures: active_tab = resolved_tab +} + +rule OpenTabInBackground { + when: OpenTabInBackgroundRequested(type, id, intent) + -- Same dedup/replace logic as OpenTab, but does NOT change active_tab +} + +rule CloseTab { + when: CloseTabRequested(tab) + ensures: not exists tab + -- If tab was active: activate next tab at same index, or last tab, or null +} + +rule PinTab { + when: PinTabRequested(tab) + ensures: tab.is_transient = false +} + +rule ClearTabs { + when: ClearTabsRequested() + ensures: tabs = empty + ensures: active_tab = null +} + +-- ─── Editor routing ─────────────────────────────────────────── + +-- The editor content area renders a view based on the active tab. + +rule ResolveEditorRoute { + when: ActiveTabChanged(active_tab) + if active_tab = null: + ensures: editor_route = dashboard + else: + ensures: editor_route = active_tab.type + -- 1:1 mapping; every tab type maps to itself as editor route +} + +-- ─── Editor views (what each route renders) ─────────────────── + +-- dashboard: Overview stats, timeline, tag cloud, recent posts +-- post: Post editor (keyed by postId) +-- media: Media editor (keyed by mediaId) +-- settings: Settings view with scrollable sections +-- style: Pico CSS theme editor +-- tags: Tag cloud, create/edit, merge sections +-- chat: AI chat panel (keyed by conversationId) +-- import: Import analysis view (keyed by definitionId) +-- menu_editor: OPML menu editor +-- metadata_diff: DB vs filesystem diff viewer +-- git_diff: Git diff view (file or commit, keyed by tab id) +-- documentation: Rendered markdown (DOCUMENTATION.md) +-- api_documentation: Rendered markdown (API.md) +-- site_validation: Generated site link/structure validation +-- translation_validation: Translation completeness checks +-- scripts: Script editor (keyed by scriptId) +-- templates: Template editor (keyed by templateId) +-- find_duplicates: Duplicate post detection via embeddings + +-- ─── Tab bar rendering ─────────────────────────────────────── + +-- Hidden when no tabs exist. +-- Horizontal strip with overflow scroll (left/right arrow buttons, 150px per click). +-- Auto-scrolls to bring active tab into view (10px padding). + +config { + tab_min_width: Integer = 100 + tab_max_width: Integer = 160 + tab_scroll_step: Integer = 150 + chat_title_max_length: Integer = 18 + git_hash_display_length: Integer = 7 +} + +value TabBarItem { + title: String -- resolved per type (see below); CSS ellipsis at tab_max_width + is_active: Boolean + is_transient: Boolean -- italic title when true + is_dirty: Boolean -- dot indicator, only for post tabs +} + +surface TabBarItemSurface { + context item: TabBarItem + + exposes: + item.title + item.is_active + item.is_transient + item.is_dirty +} + +-- Tab title resolution: +-- post: post.title from DB (listens post-updated events); no JS truncation +-- media: media.originalName; no JS truncation +-- scripts: script.title from DB (listens scripts-changed); no JS truncation +-- templates: template.title from DB (listens templates-changed); no JS truncation +-- chat: conversation.title, JS-truncated to 18 chars + "..." if over limit +-- import: definition.name (listens name-updated); no JS truncation +-- git_diff file: filename only (last path segment); no JS truncation +-- git_diff commit: "{shortHash} {subject}" (shortHash = 7 chars); fallback: 7-char hash only +-- singletons: i18n key lookup (common.settings, tabBar.style, etc.) +-- fallback: i18n:tabBar.unknown +-- +-- All tab titles are additionally CSS-truncated (text-overflow:ellipsis, white-space:nowrap) +-- within the tab's max-width of 160px. + +-- ─── Tab interactions ───────────────────────────────────────── + +-- Single click on tab: activate it +-- Double click on tab: if transient, pin it +-- Middle click on tab: close it +-- Close button: close the tab + +-- ─── Dirty tracking ────────────────────────────────────────── + +invariant DirtyIndicator { + -- Only post tabs show dirty state + -- A post tab is dirty when its in-memory content differs from saved + for tab in tabs: + tab.is_dirty = (tab.type = post and dirtyPosts.contains(tab.id)) +} + +-- ─── Tab tooltip ────────────────────────────────────────────── + +-- Base: tab title +-- If transient: append " (Preview)" +-- If dirty: append " * Modified" + +-- ─── Keyboard shortcuts ────────────────────────────────────── + +-- Ctrl/Cmd+W: close active tab +-- Ctrl/Cmd+B: toggle sidebar (see layout.allium) + +-- ─── Tab state persistence ─────────────────────────────────── + +-- Tab state (list + activeTabId) can be serialized/restored +-- for session continuity across project switches. diff --git a/specs/tag.allium b/specs/tag.allium new file mode 100644 index 0000000..0d54ff0 --- /dev/null +++ b/specs/tag.allium @@ -0,0 +1,121 @@ +-- allium: 1 +-- bDS Tag System +-- Scope: core (Wave 1) +-- Distilled from: src/main/engine/TagEngine.ts, schema.ts + +use "./project.allium" as project +use "./post.allium" as post + +surface TagControlSurface { + facing _: TagOperator + + provides: + CreateTagRequested(project, name, color) + UpdateTagRequested(tag, changes) + DeleteTagRequested(tag) + RenameTagRequested(tag, new_name) + MergeTagsRequested(sources, target) + SyncTagsFromPostsRequested(project) +} + +entity Tag { + project: project/Project + name: String + color: String? -- hex color code + post_template_slug: String? + created_at: Timestamp + updated_at: Timestamp + + -- Derived + posts: post/Post with this.name in tags + post_count: posts.count +} + +surface TagSurface { + context tag: Tag + + exposes: + tag.project + tag.name + tag.color when tag.color != null + tag.post_template_slug when tag.post_template_slug != null + tag.created_at + tag.updated_at + tag.posts.count + tag.post_count +} + +invariant UniqueTagNamePerProject { + -- Case-insensitive uniqueness + for a in Tags: + for b in Tags: + (a != b and a.project = b.project) + implies lowercase(a.name) != lowercase(b.name) +} + +invariant TagsPersistToFilesystem { + -- meta/tags.json is the portable format (no internal IDs) + -- Must stay in sync with DB tag table + parse_json(read_file("meta/tags.json")) = serialize_portable(Tags) +} + +rule CreateTag { + when: CreateTagRequested(project, name, color) + let existing_tags = Tags where project = project + requires: not existing_tags.any(t => lowercase(t.name) = lowercase(name)) + -- Case-insensitive duplicate check + ensures: Tag.created( + project: project, + name: name, + color: color + ) + ensures: TagsFileWritten(project) +} + +rule UpdateTag { + when: UpdateTagRequested(tag, changes) + ensures: TagFieldsUpdated(tag, changes) + ensures: tag.updated_at = now + ensures: TagsFileWritten(tag.project) +} + +rule DeleteTag { + when: DeleteTagRequested(tag) + -- Runs as background task, removes tag from all posts + for p in tag.posts: + ensures: p.tags = p.tags - {tag.name} + ensures: not exists tag + ensures: TagsFileWritten(tag.project) +} + +rule RenameTag { + when: RenameTagRequested(tag, new_name) + -- Runs as background task + let old_name = tag.name + for p in tag.posts: + ensures: p.tags = (p.tags - {old_name}) + {new_name} + ensures: tag.name = new_name + ensures: TagsFileWritten(tag.project) +} + +rule MergeTags { + when: MergeTagsRequested(sources, target) + -- Runs as background task + -- Merges multiple source tags into a single target + requires: sources.count >= 1 + for source in sources: + for p in source.posts: + ensures: p.tags = (p.tags - {source.name}) + {target.name} + ensures: not exists source + ensures: TagsFileWritten(target.project) +} + +rule SyncTagsFromPosts { + when: SyncTagsFromPostsRequested(project) + -- Discovers tags used in posts that are not in the tags table + for post in project.posts: + for tag_name in post.tags: + if not exists Tag{project: project, name: tag_name}: + ensures: Tag.created(project: project, name: tag_name) + ensures: TagsFileWritten(project) +} diff --git a/specs/task.allium b/specs/task.allium new file mode 100644 index 0000000..f22256f --- /dev/null +++ b/specs/task.allium @@ -0,0 +1,124 @@ +-- allium: 1 +-- bDS Background Task Manager +-- Scope: core (Wave 1) +-- Distilled from: src/main/engine/TaskManager.ts + +entity Task { + name: String + status: pending | running | completed | failed | cancelled + progress: Decimal? -- 0.0..1.0 + message: String? + group_id: String? -- Optional task grouping + group_name: String? + created_at: Timestamp + + transitions status { + pending -> running + running -> completed + running -> failed + running -> cancelled + } +} + +surface TaskControlSurface { + facing _: TaskOperator + + provides: + SubmitTaskRequested(name, work) + CancelTaskRequested(task) + RegisterExternalTaskRequested(name) +} + +surface TaskRuntimeSurface { + facing _: TaskRuntime + + provides: + TaskWorkCompleted(task) + TaskWorkFailed(task, error_message) + ProgressReported(task, value, message) +} + +surface TaskSurface { + context task: Task + + exposes: + task.name + task.status + task.progress when task.progress != null + task.message when task.message != null + task.group_id when task.group_id != null + task.group_name when task.group_name != null + task.created_at +} + +config { + max_concurrent: Integer = 3 + progress_throttle: Duration = 250.milliseconds +} + +invariant MaxConcurrency { + -- At most max_concurrent tasks run simultaneously + let running_tasks = Tasks where status = running + running_tasks.count <= config.max_concurrent +} + +invariant FifoQueue { + -- When max concurrent reached, new tasks queue in FIFO order + -- Queued tasks transition to running as slots open +} + +rule SubmitTask { + when: SubmitTaskRequested(name, work) + let running_tasks = Tasks where status = running + ensures: + let task = Task.created(name: name, status: pending) + task.status = pending + if running_tasks.count < config.max_concurrent: + task.status = running + TaskStarted(task, work) +} + +rule CompleteTask { + when: TaskWorkCompleted(task) + ensures: task.status = completed + ensures: task.progress = 1.0 + ensures: NextQueuedTaskStarted() +} + +rule FailTask { + when: TaskWorkFailed(task, error_message) + ensures: task.status = failed + ensures: task.message = error_message + ensures: NextQueuedTaskStarted() +} + +rule CancelTask { + when: CancelTaskRequested(task) + requires: task.status = running or task.status = pending + -- Cancellation uses a runtime-specific cancellation mechanism + ensures: task.status = cancelled + ensures: NextQueuedTaskStarted() +} + +rule ReportProgress { + when: ProgressReported(task, value, message) + -- Progress events throttled to 250ms + ensures: task.progress = value + ensures: task.message = message +} + +invariant ProgressThrottled { + -- Progress update events are throttled to prevent UI flooding + -- At most one progress event per 250ms per task +} + +-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts) +rule RegisterExternalTask { + when: RegisterExternalTaskRequested(name) + ensures: + let task = Task.created(name: name, status: running) + task.status = running + @guidance + -- External tasks are not managed by the queue + -- The caller is responsible for updating status +} diff --git a/specs/template.allium b/specs/template.allium new file mode 100644 index 0000000..7b1bc38 --- /dev/null +++ b/specs/template.allium @@ -0,0 +1,211 @@ +-- allium: 1 +-- bDS Liquid Template System +-- Scope: core (Wave 1 data, Wave 4 rendering) +-- Distilled from: src/main/engine/TemplateEngine.ts, PageRenderer.ts, schema.ts, +-- bundled starter templates in src/main/engine/templates/ + +entity Template { + slug: String + title: String + kind: post | list | not_found | partial + enabled: Boolean + status: draft | published + content: String? + version: Integer + file_path: String + created_at: Timestamp + updated_at: Timestamp + + -- Derived + content_location: if status = published: file_path else: content + referencing_posts: Posts where template_slug = this.slug + referencing_tags: Tags where post_template_slug = this.slug + + transitions status { + draft -> published + published -> draft + } +} + +surface TemplateManagementSurface { + facing _: TemplateOperator + + provides: + CreateTemplateRequested(title, kind, content) + CreateAndPublishTemplateRequested(title, kind, content) + UpdateTemplateRequested(template, changes) + PublishTemplateRequested(template) + DeleteTemplateRequested(template) + RebuildTemplatesFromFilesRequested(project) +} + +invariant UniqueTemplateSlug { + for a in Templates: + for b in Templates: + a != b implies a.slug != b.slug +} + +invariant TemplateFrontmatter { + -- .liquid files use standard --- YAML frontmatter + -- Fields: id, slug, title, kind, enabled, version, createdAt, updatedAt + for t in Templates where status = published: + parse_frontmatter(read_file(t.file_path)).slug = t.slug +} + +invariant TemplateFileLayout { + for t in Templates where file_path != "": + t.file_path = format("templates/{slug}.liquid", slug: t.slug) +} + +rule CreateTemplate { + when: CreateTemplateRequested(title, kind, content) + let slug = slugify(title) + -- Creates a draft template: content stored in DB, no file written yet + ensures: + let new_template = Template.created( + slug: slug, + title: title, + kind: kind, + content: content, + status: draft, + enabled: true, + version: 1, + file_path: "" + ) + new_template.status = draft +} + +rule CreateAndPublishTemplate { + -- Alternative creation path: create + immediately publish (file written) + -- Some implementations may expose this as a single user action + when: CreateAndPublishTemplateRequested(title, kind, content) + let slug = slugify(title) + requires: ValidateLiquid(content) = valid + ensures: + let new_template = Template.created( + slug: slug, + title: title, + kind: kind, + content: null, + status: published, + enabled: true, + version: 1, + file_path: format("templates/{slug}.liquid", slug: slug) + ) + TemplateFileWritten(new_template) +} + +rule UpdateTemplate { + when: UpdateTemplateRequested(template, changes) + ensures: TemplateFieldsUpdated(template, changes) + ensures: template.updated_at = now + ensures: template.version = template.version + 1 +} + +rule ReopenPublishedTemplate { + when: UpdateTemplateRequested(template, changes) + requires: template.status = published + requires: template_changes_affect_rendered_output(changes) + ensures: template.status = draft +} + +rule PublishTemplate { + when: PublishTemplateRequested(template) + requires: template.status = draft + requires: ValidateLiquid(template.content) = valid + -- The template parser must accept the template + ensures: template.status = published + ensures: TemplateFileWritten(template) + -- Writes frontmatter + liquid to templates/{slug}.liquid + ensures: template.content = null +} + +rule DeleteTemplate { + when: DeleteTemplateRequested(template) + requires: template.referencing_posts.count = 0 + requires: template.referencing_tags.count = 0 + -- Cannot delete a template still referenced by posts or tags + ensures: not exists template + ensures: TemplateFileDeleted(template) +} + +rule CascadeSlugUpdate { + when: template: Template.slug transitions_to new_slug + -- When a template slug changes, update all references + for p in template.referencing_posts: + ensures: p.template_slug = new_slug + for t in template.referencing_tags: + ensures: t.post_template_slug = new_slug +} + +rule RebuildTemplatesFromFiles { + when: RebuildTemplatesFromFilesRequested(project) + for file in scan_directory(project.effective_data_dir + "/templates", "*.liquid"): + let parsed = parse_template_file(file) + ensures: Template.created(parsed) + -- or updated if slug already exists +} + +-- Exact Liquid subset required (distilled from bundled starter templates) +-- No features beyond this list are used. + +invariant LiquidTagSubset { + -- Only these 5 tags are used: + -- {% if %} / {% elsif %} / {% else %} / {% endif %} + -- {% for %} / {% endfor %} + -- {% assign %} + -- {% render 'partial', named_param: value %} (with named parameters) + -- Whitespace-stripped variants: {%- -%} + -- + -- NOT used: include, capture, case/when, unless, raw, comment, + -- cycle, tablerow, increment, decrement, liquid, echo +} + +invariant LiquidFilterSubset { + -- Standard filters (4): + -- | escape + -- | url_encode + -- | default: fallback_value + -- | append: suffix_string + -- + -- Custom filters (2): + -- | i18n: language — translates a key string for given language + -- | markdown: post_id, post_data_json_by_id, canonical_post_path_by_slug, + -- canonical_media_path_by_source_path, language, language_prefix + -- — renders Markdown to HTML with link rewriting (6 arguments) + -- + -- NOT used: date, strip_html, truncate, split, join, size (as filter), + -- upcase, downcase, replace, remove, sort, map, where, first, last, + -- reverse, concat, uniq, compact, strip, newline_to_br, json, prepend, + -- and all math filters +} + +invariant LiquidOperatorSubset { + -- Comparison: ==, > + -- Logical: or, and + -- Truthy/falsy: bare variable in {% if variable %} + -- Special values: blank (nil/empty comparison) + -- Property access: dot notation (object.property), .size on arrays, + -- bracket notation for map lookups (map[key]) +} + +invariant LiquidRenderContext { + -- Template rendering context provides these top-level variables: + -- language, language_prefix, html_theme_attribute, + -- page_title, pico_stylesheet_href, + -- blog_languages (array of {is_current, code, flag, href_prefix}), + -- alternate_links (array of {hreflang, href}), + -- menu_items (tree of {href, title, has_children, children}), + -- calendar_initial_year, calendar_initial_month, + -- post (single post context: {title, content, id, slug, show_title}), + -- post_categories, post_tags, tag_color_by_name (map), + -- backlinks (array of {path, display_slug}), + -- day_blocks (array of {show_date_marker, date_label, posts, show_separator}), + -- archive_context ({kind, name, month, year, day}), + -- show_archive_range_heading, min_date, max_date, + -- canonical_post_path_by_slug (map), canonical_media_path_by_source_path (map), + -- post_data_json_by_id (map), + -- is_list_page, is_first_page, is_last_page, + -- has_prev_page, has_next_page, prev_page_href, next_page_href, + -- not_found_message, not_found_back_label +} diff --git a/specs/template_context.allium b/specs/template_context.allium new file mode 100644 index 0000000..9f640e0 --- /dev/null +++ b/specs/template_context.allium @@ -0,0 +1,308 @@ +-- allium: 1 +-- bDS Liquid Template Context Specification +-- Scope: core (Wave 4 — rendering parity) +-- Distilled from: ../bDS/src/main/engine/GenerationRouteRendererFactory.ts, +-- PageRenderer.ts, BlogGenerationEngine.ts +-- +-- This document specifies the exact data structure passed to Liquid templates +-- during rendering. It is the contract between the generation engine and +-- template authors. + +-- ============================================================================ +-- GLOBAL TEMPLATE VARIABLES +-- ============================================================================ + +surface TemplateRenderingSurface { + facing _: RenderPipeline + + provides: + RenderPostPageRequested(post, language) + RenderListPageRequested(posts, pagination, archive_context) + RenderNotFoundPageRequested() +} + +surface RenderContextSurface { + context context: RenderContext + + exposes: + context.language + context.language_prefix + context.page_title + context.pico_stylesheet_href + context.blog_languages + context.alternate_links + context.menu_items + context.post + context.day_blocks + context.archive_context + context.is_list_page + context.prev_page_href + context.next_page_href + context.not_found_message +} + +surface PaginationContextSurface { + context pagination: PaginationContext + + exposes: + pagination.is_first_page + pagination.is_last_page + pagination.has_prev_page + pagination.has_next_page + pagination.prev_page_href + pagination.next_page_href + pagination.current_page + pagination.total_pages + pagination.total_items + pagination.items_per_page +} + +value RenderContext { + -- Top-level variables available in all templates + language: String -- Current language code + language_prefix: String? -- "/de" or "" depending on language + html_theme_attribute: String -- Theme class for element + page_title: String -- Page title for tag + pico_stylesheet_href: String -- Path to Pico CSS theme + blog_languages: List<BlogLanguage> + alternate_links: List<AlternateLink> + menu_items: List<MenuItem> + calendar_initial_year: Integer + calendar_initial_month: Integer + post: PostContext? -- Present on single post pages + post_categories: List<Category> + post_tags: List<Tag> + tag_color_by_name: Map<String, String> + backlinks: List<Backlink> + day_blocks: List<DayBlock> + archive_context: ArchiveContext? + show_archive_range_heading: Boolean + min_date: Timestamp? + max_date: Timestamp? + is_list_page: Boolean + is_first_page: Boolean + is_last_page: Boolean + has_prev_page: Boolean + has_next_page: Boolean + prev_page_href: String? + next_page_href: String? + not_found_message: String? + not_found_back_label: String? + + -- Lookup maps for macro expansion + canonical_post_path_by_slug: Map<String, String> + canonical_media_path_by_source_path: Map<String, String> + post_data_json_by_id: Map<String, PostDataJson> +} + +value BlogLanguage { + is_current: Boolean + code: String + flag: String + href: String + href_prefix: String +} + +value AlternateLink { + href: String + hreflang: String +} + +value MenuItem { + href: String + title: String + has_children: Boolean + children: List<MenuItem>? +} + +-- ============================================================================ +-- POST CONTEXT (Single Post Pages) +-- ============================================================================ + +value PostContext { + id: String + title: String + content: String -- Rendered HTML (after markdown + macros) + slug: String + excerpt: String? + author: String? + language: String? + show_title: Boolean -- Always true for post pages + published_at: Timestamp + created_at: Timestamp + updated_at: Timestamp + tags: List<String> + categories: List<String> + template_slug: String? + do_not_translate: Boolean + linked_media: List<MediaContext> + outgoing_links: List<LinkContext> + incoming_links: List<LinkContext> +} + +value PostDataJson { + -- Serialized post data for Liquid template access + id: String + title: String + slug: String + excerpt: String? + author: String? + language: String? + published_at: Timestamp + created_at: Timestamp + updated_at: Timestamp + tags: List<String> + categories: List<String> +} + +value MediaContext { + id: String + filename: String + original_name: String + mime_type: String + title: String? + alt: String? + caption: String? + author: String? + width: Integer? + height: Integer? + file_path: String +} + +value LinkContext { + href: String + title: String + display_slug: String +} + +-- ============================================================================ +-- ARCHIVE CONTEXT (List Pages) +-- ============================================================================ + +value ArchiveContext { + kind: category | tag | date + name: String? + month: Integer? + year: Integer? + day: Integer? +} + +value DayBlock { + show_date_marker: Boolean + date_label: String + posts: List<PostContext> + show_separator: Boolean +} + +value Category { + name: String + slug: String + post_count: Integer +} + +value Tag { + name: String + slug: String + color: String? + post_count: Integer +} + +value Backlink { + path: String + display_slug: String + title: String +} + +-- ============================================================================ +-- PAGINATION CONTEXT +-- ============================================================================ + +value PaginationContext { + is_list_page: Boolean + is_first_page: Boolean + is_last_page: Boolean + has_prev_page: Boolean + has_next_page: Boolean + prev_page_href: String? + next_page_href: String? + current_page: Integer + total_pages: Integer + total_items: Integer + items_per_page: Integer +} + +-- ============================================================================ +-- LIQUID FILTER SPECIFICATION +-- Note: Allium does not have a 'filter' keyword. Filters are documented here +-- for reference but are implemented in the template engine, not in Allium specs. +-- ============================================================================ + +-- Built-in filters: +-- default: {{ value | default: "fallback" }} +-- escape: {{ text | escape }} +-- url_encode: {{ text | url_encode }} +-- append: {{ text | append: suffix }} +-- +-- Custom filters: +-- i18n: {{ "key" | i18n: language }} - translation lookup +-- markdown: {{ content | markdown: ... }} - markdown rendering with macro expansion + +-- ============================================================================ +-- BUILT-IN MACROS +-- Note: Allium does not have a 'macro' keyword. Macros are documented here +-- for reference but are implemented by the rendering subsystem. +-- ============================================================================ + +-- Built-in macros: +-- gallery: [[gallery images=post.linked_media columns=3]] +-- youtube: [[youtube id=dQw4w9WgXcQ]] +-- vimeo: [[vimeo id=123456789]] +-- photo_archive: [[photo_archive media=project.media]] +-- tag_cloud: [[tag_cloud tags=Tags]] + +-- ============================================================================ +-- TEMPLATE LOOKUP RULES +-- ============================================================================ + +invariant TemplateLookupPriority { + -- Templates are resolved in this order: + -- 1. Post-specific template (post.template_slug) + -- 2. Tag-specific template (tag.post_template_slug) + -- 3. Category-specific template (category.post_template_slug) + -- 4. Default "post" template + -- + -- List pages use "list" template + -- 404 pages use "not_found" template + -- Partials are referenced via {% render 'partial' %} +} + +invariant PartialResolution { + -- Partials are looked up in templates/ directory + -- Usage: {% render 'partials/header', context_var: value %} + -- Partial files: templates/partials/{name}.liquid +} + +-- ============================================================================ +-- RENDERING RULES +-- ============================================================================ + +rule RenderPostPage { + when: RenderPostPageRequested(post, language) + let template = resolve_template(post) + let context = BuildPostContext(post, language) + ensures: RenderedHtml = liquid_render(template.content, context) +} + +rule RenderListPage { + when: RenderListPageRequested(posts, pagination, archive_context) + let template = first (t in Templates where t.kind = "list" and t.enabled) + let context = BuildListContext(posts, pagination, archive_context) + ensures: RenderedHtml = liquid_render(template.content, context) +} + +rule RenderNotFoundPage { + when: RenderNotFoundPageRequested() + let template = first (t in Templates where t.kind = "not_found" and t.enabled) + let context = BuildNotFoundContext + ensures: RenderedHtml = liquid_render(template.content, context) +} diff --git a/specs/translation.allium b/specs/translation.allium new file mode 100644 index 0000000..9feb04e --- /dev/null +++ b/specs/translation.allium @@ -0,0 +1,171 @@ +-- allium: 1 +-- bDS Translation System +-- Scope: core (Wave 1) +-- Distilled from: src/main/engine/PostEngine.ts (translation methods), +-- postTranslationFileUtils.ts, MediaEngine.ts + +use "./post.allium" as post +use "./media.allium" as media + +surface TranslationControlSurface { + facing _: TranslationOperator + + provides: + UpsertPostTranslationRequested(post, language, title, content, excerpt) + DeletePostTranslationRequested(translation) + ValidateTranslationsRequested(project) +} + +surface TranslationRuntimeSurface { + facing _: TranslationRuntime + + provides: + PostPublished(post) + PublishTranslationRequested(translation) + TranslationEdited(translation, changes) +} + +value SupportedLanguage { + -- en, de, fr, it, es + code: String +} + +surface SupportedLanguageSurface { + context language: SupportedLanguage + + exposes: + language.code +} + +entity PostTranslation { + canonical_post: post/Post + language: SupportedLanguage + title: String + excerpt: String? + content: String? + status: draft | published + file_path: String + checksum: String? + created_at: Timestamp + updated_at: Timestamp + published_at: Timestamp? + + -- Derived + content_location: if status = published: file_path else: content + + transitions status { + draft -> published + published -> draft + } +} + +surface PostTranslationSurface { + context translation: PostTranslation + + exposes: + translation.canonical_post + translation.language.code + translation.title + translation.excerpt when translation.excerpt != null + translation.content when translation.content != null + translation.status + translation.file_path + translation.checksum when translation.checksum != null + translation.created_at + translation.updated_at + translation.published_at when translation.published_at != null + translation.content_location +} + +invariant UniqueTranslationPerLanguage { + for a in PostTranslations: + for b in PostTranslations: + (a != b and a.canonical_post = b.canonical_post) + implies a.language != b.language +} + +invariant TranslationFilePath { + -- posts/YYYY/MM/{slug}.{language}.md + for t in PostTranslations where file_path != "": + t.file_path = format("posts/{yyyy}/{mm}/{slug}.{lang}.md", + yyyy: t.canonical_post.created_at.year, + mm: t.canonical_post.created_at.month_padded, + slug: t.canonical_post.slug, + lang: t.language.code) +} + +rule UpsertPostTranslation { + when: UpsertPostTranslationRequested(post, language, title, content, excerpt) + requires: not post.do_not_translate + ensures: + let translation = PostTranslation.created( + canonical_post: post, + language: language, + title: title, + content: content, + excerpt: excerpt, + status: draft, + file_path: "" + ) + translation.status = draft + -- If translation already exists, update it instead +} + +rule PublishPostTranslation { + when: PostPublished(post) + -- All translations are also published when the canonical post is published + for t in post.translations: + ensures: PublishTranslationRequested(t) +} + +rule PublishTranslation { + when: PublishTranslationRequested(translation) + requires: translation.status = draft + ensures: translation.status = published + ensures: translation.published_at = translation.published_at ?? now + ensures: TranslationFileWritten(translation) + ensures: translation.content = null + -- Content moves to filesystem +} + +rule ReopenPublishedTranslation { + when: TranslationEdited(translation, changes) + requires: translation.status = published + requires: translation_edit_affects_published_content(changes) + ensures: translation.status = draft + ensures: translation.updated_at = now +} + +rule DeletePostTranslation { + when: DeletePostTranslationRequested(translation) + ensures: not exists translation + ensures: TranslationFileDeleted(translation) + ensures: SearchIndexUpdated(translation.canonical_post) + -- FTS index includes all translations of a post +} + +rule ValidateTranslations { + when: ValidateTranslationsRequested(project) + -- Checks all posts against configured blog languages + -- Reports: missing translations, orphan translation files, + -- posts marked do_not_translate + for post in project.posts where status = published: + for lang in project.blog_languages: + if lang != project.main_language: + if not post.do_not_translate: + if not (lang in post.available_languages): + ensures: ValidationIssueReported(post, lang, "missing") + + @guidance + -- This produces a validation report, not automatic fixes + -- The report drives targeted re-rendering +} + +invariant FtsIncludesTranslations { + -- Full-text search index for a post includes stemmed content + -- from all its translations, not just the canonical language + for post in Posts: + includes_text(search_index(post), post.title) + for t in post.translations: + includes_text(search_index(post), t.title) +} diff --git a/specs/ui_data_flow.allium b/specs/ui_data_flow.allium new file mode 100644 index 0000000..e10dfe9 --- /dev/null +++ b/specs/ui_data_flow.allium @@ -0,0 +1,203 @@ +-- allium: 1 +-- bDS UI Data Flow Model +-- Scope: cross-cutting (all waves) +-- Distilled from: appStore.ts, PostEditor.tsx, MediaEditor.tsx, Editor.tsx, +-- Sidebar.tsx, NotificationWatcher.ts + +-- Describes the reactive data flows that connect sidebar, editor, tab bar, +-- and backend engine operations. Covers: sidebar->editor, editor->sidebar, +-- cross-tab coordination, and backend->UI event propagation. + +use "./layout.allium" as layout +use "./tabs.allium" as tabs +use "./sidebar_views.allium" as sidebar + +-- ─── External surfaces ────────────────────────────────────── + +-- User-initiated navigation and entity management actions. +-- These triggers originate from sidebar clicks, tab operations, +-- dashboard interactions, and save/delete actions in editors. + +surface UserNavigation { + provides: SidebarItemClicked(entity_type, entity_id, click_type) + provides: SidebarCreateRequested(entity_type) + provides: SidebarDeleteRequested(entity_type, entity_id) + provides: DashboardPostClicked(post_id, click_type) + provides: PostSaved(post_id, updated_post) + provides: PostStatusTransitioned(post_id, old_status, new_status) + provides: PostDeletedFromEditor(post_id) + provides: MediaSavedFromEditor(media_id, updated_media) + provides: MediaDeletedFromEditor(media_id) + provides: SettingsRebuildCompleted(entity_type, new_data) + provides: TransientTabBeingReplaced(old_tab, new_tab) + provides: TabClosed(tab) +} + +-- ─── 1. Sidebar -> Editor flows ────────────────────────────── + +-- All UI coordination flows through shared application state. +-- There are no direct calls between sidebar and editor. +-- Both read from and write to the same state model; changes propagate +-- reactively. + +rule SidebarEntityClick { + when: SidebarItemClicked(entity_type, entity_id, click_type) + -- Single click: open as transient/preview tab + -- Double click: open as pinned tab + -- See sidebar_views.allium for per-view click rules + -- Effect: Editor mounts the matching view keyed by entity_id + -- Effect: Sidebar item highlights based on activeTabId match + -- If a prior transient tab of same type exists, it is replaced, + -- and the old editor unmounts (triggering auto-save if dirty) +} + +rule SidebarCreateEntity { + when: SidebarCreateRequested(entity_type) + -- Posts/Pages: backend creates post, emits post:created, + -- store adds to posts list, sidebar re-renders with new item. + -- Does NOT open a tab automatically. User must click to open. + -- Sets selectedPostId for highlighting, optionally ensures sidebar visible. + -- Scripts/Templates: backend creates, emits event, opens tab immediately. + -- Chat: creates conversation, opens as pinned tab. + -- Import: creates definition, opens as pinned tab. +} + +rule SidebarDeleteEntity { + when: SidebarDeleteRequested(entity_type, entity_id) + -- Scripts/Templates/Chat/Import: backend deletes entity, + -- then closeTab(entity_id) removes its tab, + -- activates adjacent tab or shows dashboard. + -- Posts/Media: deletion is triggered from the EDITOR (not sidebar). + -- See editor_post.allium PostDeleteAction / editor_media.allium MediaDeleteAction. +} + +invariant SidebarFilterIsolation { + -- Sidebar search/filter state is local to the sidebar component. + -- Filtering never affects: active tab, editor content, selectedPostId. + -- Only the visible list of items changes. +} + +-- ─── 2. Editor -> Sidebar flows ────────────────────────────── + +rule PostTitleChanged { + when: PostSaved(post_id, updated_post) + -- Auto-save fires after 3s idle, or on Ctrl+S, or on unmount/tab switch + -- Store's posts array is updated with new title/metadata + -- Sidebar PostsList re-renders reactively showing new title + -- TabBar re-renders showing new title + ensures: dirtyPosts.remove(post_id) +} + +rule PostStatusChanged { + when: PostStatusTransitioned(post_id, old_status, new_status) + -- Store's posts array is updated with new status + -- Sidebar detects status change (compares prev/current status maps) + -- and re-runs search/filter if active + -- Post moves between draft/published/archived sections in sidebar +} + +rule PostEditorDelete { + when: PostDeletedFromEditor(post_id) + ensures: store.removePost(post_id) + -- Removes from posts array, clears selectedPostId if matching, + -- removes from dirtyPosts + ensures: closeTab(post_id) + -- Sidebar re-renders without the post +} + +rule MediaEditorSave { + when: MediaSavedFromEditor(media_id, updated_media) + ensures: store.updateMedia(media_id, updated_media) + -- Sidebar MediaList re-renders with updated metadata +} + +rule MediaEditorDelete { + when: MediaDeletedFromEditor(media_id) + ensures: store.removeMedia(media_id) + -- Editor.tsx safety net: detects active media tab references + -- non-existent item, calls closeTab(activeTab.id) + -- Sidebar re-renders without the media item +} + +rule SettingsRebuild { + when: SettingsRebuildCompleted(entity_type, new_data) + -- Wholesale replacement of posts or media array in store + ensures: store.setPosts(new_data) or store.setMedia(new_data) + -- Sidebar re-renders entirely with fresh data +} + +-- ─── 3. Cross-tab coordination ────────────────────────────── + +invariant TabSwitchDoesNotChangeSidebarView { + -- Switching tabs does NOT change activeView in the sidebar. + -- The sidebar view is controlled exclusively by the Activity Bar. + -- Exception: Dashboard post click explicitly sets activeView = "posts". +} + +invariant SidebarHighlightFollowsActiveTab { + -- Sidebar item highlight is based on activeTabId === entity.id, + -- NOT on selectedPostId/selectedMediaId (which are separate concepts + -- used only for post creation flow). +} + +rule TransientTabReplacement { + when: TransientTabBeingReplaced(old_tab, new_tab) + -- Old editor unmounts -> cleanup auto-save fires if dirty + -- New editor mounts with new entity + -- Sidebar highlight shifts to newly active entity +} + +rule TabCloseCleanup { + when: TabClosed(tab) + -- If post tab: PostEditor unmounts, auto-save fires if dirty + -- Store activates adjacent tab (prefer right, then left, then null) + -- Sidebar highlight updates to new active tab + -- If no tabs remain: dashboard shown +} + +rule DashboardPostClick { + when: DashboardPostClicked(post_id, click_type) + -- This is the ONLY place where opening a tab also switches sidebar view + ensures: setActiveView("posts") + ensures: setSelectedPost(post_id) + if click_type = single: + ensures: OpenTabRequested(type: post, id: post_id, intent: preview) + if click_type = double: + ensures: OpenTabRequested(type: post, id: post_id, intent: pin) +} + +-- ─── 4. Backend -> UI event propagation ───────────────────── + +-- Backend engines emit events via IPC. The renderer listens and updates +-- the shared store. Both sidebar and editor re-render reactively. +-- Events: post:created, post:updated, post:deleted, +-- media:imported, media:updated, media:deleted, +-- template:created/updated/deleted, script:created/updated/deleted, +-- entity:changed (from CLI/MCP mutations via NotificationWatcher) + +-- TabBar also listens directly for: +-- post-updated (title refresh) +-- bds:scripts-changed (script title refresh) +-- BDS_EVENT_TEMPLATES_CHANGED (template title refresh) +-- chat.onTitleUpdated (chat conversation title refresh) +-- importDefinitions.onNameUpdated (import name refresh) + +-- Editor.tsx has safety-net useEffect guards that: +-- Close media tabs when referenced media no longer exists in store +-- Clear selectedPostId/selectedMediaId when entity is gone + +-- ─── 5. Keyboard shortcut map ───────────────────────────── + +-- All shortcuts use Cmd on macOS, Ctrl on other platforms. + +-- Global: +-- Ctrl/Cmd+B: toggle sidebar visibility +-- Ctrl/Cmd+W: close active tab + +-- Post editor: +-- Ctrl/Cmd+S: save post immediately (resets auto-save timer) +-- Ctrl/Cmd+K: open InsertModal (insert post link) + +-- Sidebar lists: +-- Enter: open selected item as pinned tab +-- Space: open selected item as preview/transient tab diff --git a/test/bds_test.exs b/test/bds_test.exs new file mode 100644 index 0000000..a7afc68 --- /dev/null +++ b/test/bds_test.exs @@ -0,0 +1,7 @@ +defmodule BDSTest do + use ExUnit.Case, async: false + + test "the repo module is configured" do + assert BDS.Repo.config()[:otp_app] == :bds + end +end \ No newline at end of file diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..3a684e0 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, :manual) \ No newline at end of file