Formalize tier-1 comment-based specs into allium constructs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,114 +15,215 @@ surface UserAction {
|
||||
provides: AISuggestionRequested(entity_type, entity_id)
|
||||
provides: PostSaved(post_id)
|
||||
provides: PostAutoTranslateCompleted(post_id, language)
|
||||
provides: ImageFileDroppedOnPostEditor(post_id, file)
|
||||
provides: AISuggestionsModalConfirmed(entity_type, entity_id, fields)
|
||||
provides: AISuggestionsModalCancelled(entity_type, entity_id)
|
||||
}
|
||||
|
||||
-- ─── AI operation gating ───────────────────────────────
|
||||
surface AiEngine {
|
||||
facing _: AiRuntime
|
||||
|
||||
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
|
||||
provides: AiFieldSuggestionsResponded(entity_type, entity_id, suggestions)
|
||||
-- The active endpoint's response, parsed into per-field suggestions
|
||||
}
|
||||
|
||||
-- ─── AI suggestions pattern ────────────────────────────
|
||||
-- ─── AI operation gating ────────────────────────────────────
|
||||
--
|
||||
-- All AI operations route through the active endpoint for the current
|
||||
-- mode; endpoint selection and the active_endpoint_configured predicate
|
||||
-- are specified in ai.allium (AirplaneModeGating, TwoEndpointModel).
|
||||
-- Rules below gate on active_endpoint_configured and refuse with a
|
||||
-- toast when it is false. Two model categories are used:
|
||||
-- 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 the chat panel only.
|
||||
|
||||
-- Shared flow used by PostAIAnalysis and MediaAIImageAnalysis.
|
||||
-- See modals.allium AISuggestionsModal value type.
|
||||
|
||||
rule AISuggestionFlow {
|
||||
rule AiOperationRefusedWithoutEndpoint {
|
||||
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
|
||||
requires: not active_endpoint_configured
|
||||
ensures: ToastShown(message_key: "ai.unavailable_configure_endpoint")
|
||||
-- "AI unavailable — configure {online|airplane} endpoint in Settings"
|
||||
ensures: AiOperationAborted(entity_type, entity_id)
|
||||
}
|
||||
|
||||
-- ─── Auto-translation chain ────────────────────────────
|
||||
-- ─── AI suggestions pattern ─────────────────────────────────
|
||||
--
|
||||
-- Shared flow used by PostAIAnalysis (editor_post.allium) and
|
||||
-- MediaAIImageAnalysis (editor_media.allium). Modal layout:
|
||||
-- see modals.allium AISuggestionsModal / AISuggestionField.
|
||||
|
||||
config {
|
||||
post_suggestion_sample_length: Integer = 2000
|
||||
}
|
||||
|
||||
rule PostAISuggestionFlow {
|
||||
when: AISuggestionRequested(entity_type, entity_id)
|
||||
requires: entity_type = "post"
|
||||
requires: active_endpoint_configured
|
||||
let target = post/Post{id: entity_id}
|
||||
ensures: AiFieldSuggestionsRequested(entity_id,
|
||||
model: "title",
|
||||
input: post_suggestion_input(target))
|
||||
-- black box: title + excerpt + first
|
||||
-- config.post_suggestion_sample_length chars of content
|
||||
}
|
||||
|
||||
rule MediaAISuggestionFlow {
|
||||
when: AISuggestionRequested(entity_type, entity_id)
|
||||
requires: entity_type = "media"
|
||||
requires: active_endpoint_configured
|
||||
let target = media/Media{id: entity_id}
|
||||
ensures: AiFieldSuggestionsRequested(entity_id,
|
||||
model: "image",
|
||||
input: ai_thumbnail(target))
|
||||
-- black box: the 448x448 AI thumbnail JPEG
|
||||
}
|
||||
|
||||
rule PresentAiSuggestions {
|
||||
when: AiFieldSuggestionsResponded(entity_type, entity_id, suggestions)
|
||||
-- Response parsed into per-field suggestions; each field row shows
|
||||
-- label, current value, suggested value, and an accept checkbox
|
||||
-- defaulting to true. The post slug field is locked (no checkbox)
|
||||
-- if the post was ever published.
|
||||
ensures: AISuggestionsModalOpened(entity_type, entity_id, suggestions)
|
||||
}
|
||||
|
||||
rule ApplyAcceptedSuggestions {
|
||||
when: AISuggestionsModalConfirmed(entity_type, entity_id, fields)
|
||||
let accepted = filter(fields, f => f.accepted and not f.locked)
|
||||
for field in accepted:
|
||||
ensures: SuggestionFieldApplied(entity_type, entity_id, field)
|
||||
ensures:
|
||||
if entity_type = "post":
|
||||
PostAutoSaveTimerReset(entity_id)
|
||||
-- Posts: applied fields go through the 3s auto-save timer
|
||||
else:
|
||||
MediaSaveRequested(entity_id)
|
||||
-- Media: applied fields trigger an explicit save
|
||||
}
|
||||
|
||||
rule CancelAiSuggestions {
|
||||
when: AISuggestionsModalCancelled(entity_type, entity_id)
|
||||
ensures: AiSuggestionsDiscarded(entity_type, entity_id)
|
||||
-- All suggestions discarded, no entity changes
|
||||
}
|
||||
|
||||
-- ─── Auto-translation chain ─────────────────────────────────
|
||||
|
||||
rule AutoTranslationChain {
|
||||
when: PostSaved(post_id)
|
||||
-- Gate: AIOperationGating + post.doNotTranslate must be false
|
||||
-- Triggered only by explicit manual save or publish, never by auto-save,
|
||||
-- unmount/tab-switch persistence, post creation, import, or scripting.
|
||||
-- For each configured blogLanguage missing a translation for this post:
|
||||
-- 1. Enqueue one background task for that language
|
||||
-- 2. Translate metadata (title, excerpt) via title model
|
||||
-- 3. Translate content (full markdown) via title model
|
||||
-- 4. Create/update translation record in DB
|
||||
-- A later manual save with no missing languages enqueues no task.
|
||||
-- Work is sequential within a language and parallel across languages.
|
||||
-- Progress visible in Tasks panel
|
||||
-- Triggered only by explicit manual save or publish, never by
|
||||
-- auto-save, unmount/tab-switch persistence, post creation, import,
|
||||
-- or scripting.
|
||||
let saved_post = post/Post{id: post_id}
|
||||
requires: active_endpoint_configured
|
||||
requires: not saved_post.do_not_translate
|
||||
let missing = saved_post.project.blog_languages - saved_post.available_languages
|
||||
for language in missing:
|
||||
ensures: PostTranslationTaskEnqueued(post_id, language)
|
||||
-- One background task per missing language. Each task:
|
||||
-- 1. translate metadata (title, excerpt) via title model
|
||||
-- 2. translate content (full markdown) via title model
|
||||
-- 3. create/update the translation record in the DB
|
||||
-- Sequential within a language, parallel across languages;
|
||||
-- progress visible in the Tasks panel. A save with no missing
|
||||
-- languages enqueues nothing (empty set).
|
||||
}
|
||||
|
||||
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
|
||||
let saved_post = post/Post{id: post_id}
|
||||
for link in saved_post.linked_media:
|
||||
let item = media/Media{id: link.media_id}
|
||||
if item.language != null
|
||||
and not exists media/MediaTranslation{media: item, language: language}:
|
||||
ensures: MediaMetadataTranslationTaskEnqueued(link.media_id, language)
|
||||
-- Translates title, alt, caption via the title model and
|
||||
-- writes the translated sidecar {path}.{lang}.meta
|
||||
}
|
||||
|
||||
-- ─── Drag-and-drop image chain ─────────────────────────
|
||||
-- ─── Drag-and-drop image chain ──────────────────────────────
|
||||
--
|
||||
-- Full chain when an image file is dropped on the post editor body.
|
||||
-- See editor_post.allium PostDragDropImage for the trigger rule.
|
||||
|
||||
-- Full chain when image file is dropped on post editor body.
|
||||
-- See editor_post.allium PostDragDropImage for trigger rule.
|
||||
rule PostDragDropImageChain {
|
||||
when: ImageFileDroppedOnPostEditor(post_id, file)
|
||||
let target = post/Post{id: post_id}
|
||||
-- Synchronous steps (user waits):
|
||||
let imported = import_media(target.project, file)
|
||||
-- black box: new media record + file copy + base sidecar
|
||||
-- (media.allium ImportMedia)
|
||||
ensures: ThumbnailGenerationStarted(imported)
|
||||
-- async start: small/medium/large/ai variants
|
||||
ensures: MediaLinkedToPost(imported, target)
|
||||
-- updates sidecar linkedPostIds
|
||||
ensures: MarkdownImageInserted(target, imported)
|
||||
-- inserts  at the cursor position
|
||||
ensures: DragDropImportCompleted(target, imported)
|
||||
}
|
||||
|
||||
-- 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  at cursor
|
||||
rule DragDropAiAutoApply {
|
||||
when: DragDropImportCompleted(target, imported)
|
||||
requires: active_endpoint_configured
|
||||
-- Background, non-blocking: image model on the 448x448 AI thumbnail.
|
||||
-- Results are auto-applied WITHOUT the AISuggestionsModal (unlike
|
||||
-- the manual Quick Action).
|
||||
ensures: MediaMetadataUpdated(imported, ai_image_analysis(imported))
|
||||
ensures: MediaSidecarRewritten(imported)
|
||||
}
|
||||
|
||||
-- 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
|
||||
rule DragDropTranslationCascade {
|
||||
when: DragDropImportCompleted(target, imported)
|
||||
requires: active_endpoint_configured
|
||||
requires: not target.do_not_translate
|
||||
for language in target.project.blog_languages:
|
||||
ensures: MediaMetadataTranslationTaskEnqueued(imported.id, language)
|
||||
-- creates translated sidecar files
|
||||
}
|
||||
|
||||
-- ─── Confirmation dialog patterns ──────────────────────
|
||||
-- ─── Confirmation dialog patterns ───────────────────────────
|
||||
--
|
||||
-- Four distinct patterns used across the application. The assignment
|
||||
-- table below is the authoritative mapping from destructive or
|
||||
-- irreversible actions to their confirmation UI.
|
||||
|
||||
-- Four distinct patterns used across the application:
|
||||
enum ConfirmationPattern {
|
||||
system_confirm
|
||||
-- Simple yes/no system dialog, no custom UI
|
||||
confirm_delete_modal
|
||||
-- Custom modal with entity name, reference counts, linked
|
||||
-- entity list; buttons Cancel + Delete (destructive red style)
|
||||
confirm_dialog
|
||||
-- Custom modal describing the action and its consequences;
|
||||
-- buttons Cancel + Confirm
|
||||
none
|
||||
-- Immediate execution on click, no dialog
|
||||
}
|
||||
|
||||
-- Pattern 1: System confirm dialog
|
||||
-- Simple yes/no system dialog, no custom UI
|
||||
-- Used by: PostDelete, PostDiscard, TemplateDelete (when references exist)
|
||||
value ConfirmationAssignment {
|
||||
action: String
|
||||
pattern: ConfirmationPattern
|
||||
}
|
||||
|
||||
-- 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
|
||||
config {
|
||||
confirmation_assignments: Set<ConfirmationAssignment> = {
|
||||
ConfirmationAssignment(action: "post_delete", pattern: system_confirm),
|
||||
ConfirmationAssignment(action: "post_discard", pattern: system_confirm),
|
||||
ConfirmationAssignment(action: "template_delete_with_references", pattern: system_confirm),
|
||||
ConfirmationAssignment(action: "media_delete", pattern: confirm_delete_modal),
|
||||
-- shows linked posts
|
||||
ConfirmationAssignment(action: "tag_delete", pattern: confirm_delete_modal),
|
||||
-- shows post count
|
||||
ConfirmationAssignment(action: "tag_merge", pattern: confirm_dialog),
|
||||
-- "Merge N tags into {target}? Cannot be undone."
|
||||
ConfirmationAssignment(action: "rebuild_operations", pattern: none),
|
||||
ConfirmationAssignment(action: "script_delete", pattern: none),
|
||||
ConfirmationAssignment(action: "menu_item_delete", pattern: none),
|
||||
ConfirmationAssignment(action: "metadata_diff_field_sync", pattern: none),
|
||||
ConfirmationAssignment(action: "import_execute", pattern: none),
|
||||
ConfirmationAssignment(action: "media_translation_delete", pattern: none),
|
||||
ConfirmationAssignment(action: "media_unlink", pattern: none)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
-- the main content area when a media tab is active.
|
||||
|
||||
use "./media.allium" as media
|
||||
use "./post.allium" as post
|
||||
use "./i18n.allium" as i18n
|
||||
|
||||
-- ─── Media editor ─────────────────────────────────────────────
|
||||
@@ -56,6 +57,9 @@ surface PostPickerOverlaySurface {
|
||||
result.post_id
|
||||
result.title
|
||||
overlay.overflow_count when overlay.overflow_count != null
|
||||
|
||||
provides:
|
||||
PostPickerResultClicked(media_id, post_id)
|
||||
}
|
||||
|
||||
value PostPickerResult {
|
||||
@@ -103,6 +107,8 @@ surface MediaEditorSurface {
|
||||
MediaTranslationRefreshClicked(editor.media_id, language)
|
||||
MediaTranslationDeleteClicked(editor.media_id, language)
|
||||
MediaUnlinkPostRequested(editor.media_id, post_id)
|
||||
MediaDeleteConfirmed(editor.media_id)
|
||||
TranslationEditModalSaved(editor.media_id, language, title, alt, caption)
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Header bar with media display name.
|
||||
@@ -162,84 +168,153 @@ surface MediaEditorSurface {
|
||||
|
||||
-- ─── Media editor actions ─────────────────────────────────────
|
||||
|
||||
rule MediaSave {
|
||||
when: MediaSaveRequested(media_id)
|
||||
let item = media/Media{id: media_id}
|
||||
ensures: UpdateMediaRequested(item, editor_field_changes(media_id))
|
||||
-- Persists all form fields; the engine update rewrites the
|
||||
-- sidecar and FTS entry (media.allium UpdateMedia,
|
||||
-- engine_side_effects.allium UpdateMediaSideEffects)
|
||||
}
|
||||
|
||||
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
|
||||
-- Only available for image/* MIME types (editor.is_image guard
|
||||
-- on the provides entry; button hidden for non-images).
|
||||
ensures: AISuggestionRequested(entity_type: "media", entity_id: media_id)
|
||||
-- Shared flow: action_patterns.allium MediaAISuggestionFlow —
|
||||
-- airplane gating, image model over the 448x448 AI thumbnail,
|
||||
-- AISuggestionsModal with title/alt/caption, accepted fields
|
||||
-- trigger an 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
|
||||
let item = media/Media{id: media_id}
|
||||
requires: active_endpoint_configured
|
||||
-- airplane-mode gating: ai.allium AirplaneModeGating
|
||||
let detected = ai_detect_language(concat(item.title, item.alt, item.caption))
|
||||
-- title model over the concatenated metadata text
|
||||
ensures: UpdateMediaRequested(item, language_change(detected))
|
||||
-- persisted immediately — no modal, no confirmation; the
|
||||
-- engine update rewrites the sidecar
|
||||
}
|
||||
|
||||
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
|
||||
let item = media/Media{id: media_id}
|
||||
requires: active_endpoint_configured
|
||||
-- target_language comes from the language picker modal (same
|
||||
-- pattern as post translate).
|
||||
let source_language = item.language
|
||||
?? ai_detect_language(concat(item.title, item.alt, item.caption))
|
||||
-- step 1: detect the source language first when unset
|
||||
-- (auto-persisted, as in MediaDetectLanguage)
|
||||
let translated = ai_translate_media_metadata(item, source_language, target_language)
|
||||
-- step 2: translate title, alt, caption via the title model
|
||||
ensures: UpsertMediaTranslationRequested(item, target_language,
|
||||
translated.title, translated.alt, translated.caption)
|
||||
-- creates/updates the translation record and writes the
|
||||
-- translated sidecar {path}.{lang}.meta (media.allium
|
||||
-- UpsertMediaTranslation)
|
||||
}
|
||||
|
||||
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
|
||||
let item = media/Media{id: media_id}
|
||||
let source = native_file_dialog_selection()
|
||||
-- native file dialog, no MIME type filter; cancel aborts
|
||||
ensures: ReplaceMediaFileRequested(item, source)
|
||||
-- copies over the existing media file path; images regenerate
|
||||
-- thumbnails synchronously (media.allium ReplaceMediaFile)
|
||||
ensures: MediaPreviewRefreshed(media_id)
|
||||
-- preview reloads with a 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
|
||||
let item = media/Media{id: media_id}
|
||||
ensures: ConfirmDeleteModalOpened(media_id, item.linked_posts)
|
||||
-- custom modal (not a native dialog): media display name,
|
||||
-- linked post count and list; Cancel + Delete (destructive
|
||||
-- red style) — action_patterns.allium
|
||||
-- confirmation_assignments: media_delete
|
||||
}
|
||||
|
||||
rule MediaDeleteExecute {
|
||||
when: MediaDeleteConfirmed(media_id)
|
||||
let item = media/Media{id: media_id}
|
||||
ensures: DeleteMediaRequested(item)
|
||||
-- file, sidecar, thumbnails, translations, post-media links,
|
||||
-- FTS entry: media.allium DeleteMedia +
|
||||
-- engine_side_effects.allium DeleteMediaSideEffects
|
||||
ensures: closeTab(media_id)
|
||||
-- sidebar removes the item reactively
|
||||
}
|
||||
|
||||
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
|
||||
ensures: PostPickerOverlayOpened(media_id)
|
||||
-- inline overlay near the button (not a modal): search input
|
||||
-- filters unlinked posts by title, at most
|
||||
-- config.media_post_picker_max_results results, "and N more"
|
||||
-- when the total exceeds the limit
|
||||
}
|
||||
|
||||
rule MediaLinkToPostSelected {
|
||||
when: PostPickerResultClicked(media_id, post_id)
|
||||
let item = media/Media{id: media_id}
|
||||
ensures: MediaLinkedToPost(item, post/Post{id: post_id})
|
||||
-- creates the post-media link and updates sidecar
|
||||
-- linkedPostIds; the linked posts list refreshes reactively
|
||||
ensures: PostPickerOverlayClosed(media_id)
|
||||
}
|
||||
|
||||
rule MediaUnlinkPost {
|
||||
when: MediaUnlinkPostRequested(media_id, post_id)
|
||||
let item = media/Media{id: media_id}
|
||||
-- No confirmation (action_patterns.allium
|
||||
-- confirmation_assignments: media_unlink)
|
||||
ensures: MediaUnlinkedFromPost(item, post_id)
|
||||
-- removes the post-media link row and updates sidecar
|
||||
-- linkedPostIds
|
||||
}
|
||||
|
||||
rule MediaTranslationEdit {
|
||||
when: MediaTranslationEditClicked(media_id, language)
|
||||
-- Opens the "Edit Translation" modal pre-filled with the translation's
|
||||
-- title, alt, and caption for that language
|
||||
-- Save persists to DB + translated sidecar {path}.{lang}.meta; Cancel discards
|
||||
ensures: TranslationEditModalOpened(media_id, language)
|
||||
-- "Edit Translation" modal pre-filled with the translation's
|
||||
-- title, alt, and caption; Cancel/close discards
|
||||
}
|
||||
|
||||
rule MediaTranslationEditSave {
|
||||
when: TranslationEditModalSaved(media_id, language, title, alt, caption)
|
||||
let item = media/Media{id: media_id}
|
||||
ensures: UpsertMediaTranslationRequested(item, language, title, alt, caption)
|
||||
-- persists the DB record + 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
|
||||
let item = media/Media{id: media_id}
|
||||
requires: active_endpoint_configured
|
||||
let translated = ai_translate_media_metadata(item, item.language, language)
|
||||
-- re-translates from the source language via the title model
|
||||
ensures: UpsertMediaTranslationRequested(item, language,
|
||||
translated.title, translated.alt, translated.caption)
|
||||
-- overwrites the existing translation fields and rewrites the
|
||||
-- translated sidecar
|
||||
}
|
||||
|
||||
rule MediaTranslationDelete {
|
||||
when: MediaTranslationDeleteClicked(media_id, language)
|
||||
-- Deletes translation record from DB
|
||||
-- Deletes translated sidecar file: {path}.{lang}.meta
|
||||
-- No confirmation dialog
|
||||
let item = media/Media{id: media_id}
|
||||
let translation = media/MediaTranslation{media: item, language: language}
|
||||
-- No confirmation dialog (action_patterns.allium
|
||||
-- confirmation_assignments: media_translation_delete)
|
||||
ensures: not exists translation
|
||||
ensures: MediaTranslationDeleted(item, language)
|
||||
-- engine_side_effects.allium DeleteMediaTranslationSideEffects
|
||||
-- deletes the translated sidecar {path}.{lang}.meta
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ surface PostEditorSurface {
|
||||
PostGalleryRequested(editor.post_id)
|
||||
ImageDroppedOnEditor(editor.post_id, file_path)
|
||||
PostLanguageDetectRequested(editor.post_id)
|
||||
PostDiscardConfirmed(editor.post_id)
|
||||
PostDeleteConfirmed(editor.post_id)
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Header bar with two areas.
|
||||
@@ -207,119 +209,136 @@ invariant PostEditorModePersistence {
|
||||
|
||||
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
|
||||
ensures: AISuggestionRequested(entity_type: "post", entity_id: post_id)
|
||||
-- Shared flow: action_patterns.allium PostAISuggestionFlow —
|
||||
-- airplane gating, title model (not the default chat model)
|
||||
-- over title + excerpt + content sample, AISuggestionsModal
|
||||
-- with title/excerpt/slug (slug locked once published),
|
||||
-- accepted fields re-arm the auto-save timer.
|
||||
}
|
||||
|
||||
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)
|
||||
let target = post/Post{id: post_id}
|
||||
requires: active_endpoint_configured
|
||||
-- airplane-mode gating: ai.allium AirplaneModeGating
|
||||
requires: target_language in target.project.blog_languages
|
||||
-- chosen in the language picker modal; existing translations
|
||||
-- are listed with their status badge (draft/published)
|
||||
let translated_meta = ai_translate_post_metadata(target, target_language)
|
||||
-- AI call 1: title + excerpt via the title model
|
||||
let translated_content = ai_translate_post_content(target, target_language)
|
||||
-- AI call 2 (sequential): full markdown body via the title model
|
||||
ensures: PostTranslationUpserted(
|
||||
translation_record(target, target_language, translated_meta, translated_content),
|
||||
target)
|
||||
-- creates/updates the translation record; a published source
|
||||
-- post transitions back to draft with the file content copied
|
||||
-- to the DB so it can be edited (engine_side_effects.allium
|
||||
-- UpsertPostTranslationSideEffects)
|
||||
}
|
||||
|
||||
rule PostAutoTranslateOnSave {
|
||||
when: PostSaved(post_id)
|
||||
-- Only an explicit manual save (or publish) triggers this. Auto-saves and
|
||||
-- post creation (sidebar, deeplink/bookmarklet, import, scripting) never do.
|
||||
-- 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
|
||||
}
|
||||
-- Auto-translation on manual save is specified once in
|
||||
-- action_patterns.allium AutoTranslationChain (triggered by PostSaved),
|
||||
-- including the linked-media cascade. It is not duplicated here.
|
||||
|
||||
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
|
||||
let target = post/Post{id: post_id}
|
||||
ensures:
|
||||
if editor_is_dirty(post_id):
|
||||
PostSaved(post_id)
|
||||
-- implicit save first (awaited)
|
||||
ensures: PublishPostRequested(target)
|
||||
-- engine publish: file write, translation publishing, FTS,
|
||||
-- links (engine_side_effects.allium PublishPostSideEffects)
|
||||
-- UI updates reactively: status badge -> published, sidebar
|
||||
-- section move (ui_data_flow.allium PostStatusChanged)
|
||||
}
|
||||
|
||||
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
|
||||
let target = post/Post{id: post_id}
|
||||
requires: target.published_at != null
|
||||
requires: target.status = draft
|
||||
-- only published posts with pending draft changes
|
||||
ensures: DiscardConfirmShown(post_id)
|
||||
-- system confirm dialog: "Discard changes to this post?"
|
||||
-- (action_patterns.allium confirmation_assignments:
|
||||
-- post_discard)
|
||||
}
|
||||
|
||||
rule PostDiscardExecute {
|
||||
when: PostDiscardConfirmed(post_id)
|
||||
let target = post/Post{id: post_id}
|
||||
ensures: DiscardPostChangesRequested(target)
|
||||
-- engine restores the published state from the .md file:
|
||||
-- content = null, status = published
|
||||
ensures: EditorReloaded(post_id)
|
||||
-- editor reloads with the 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
|
||||
ensures: DeleteConfirmShown(post_id)
|
||||
-- system confirm dialog: "Delete this post?"
|
||||
-- (action_patterns.allium confirmation_assignments: post_delete)
|
||||
}
|
||||
|
||||
rule PostDeleteExecute {
|
||||
when: PostDeleteConfirmed(post_id)
|
||||
let target = post/Post{id: post_id}
|
||||
ensures: DeletePostRequested(target)
|
||||
-- published posts also delete the .md file and all translation
|
||||
-- files; never-published drafts only the DB record
|
||||
-- (engine_side_effects.allium DeletePostSideEffects)
|
||||
ensures: closeTab(post_id)
|
||||
-- sidebar removes the item reactively (ui_data_flow.allium
|
||||
-- PostEditorDelete)
|
||||
}
|
||||
|
||||
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
|
||||
-- Keyboard shortcut: Ctrl/Cmd+K (markdown mode only, per the
|
||||
-- provides guard)
|
||||
ensures: InsertPostLinkModalOpened(post_id)
|
||||
-- two tabs: Internal (debounced title search, similarity
|
||||
-- ranking, Create Post row) and External (URL + display text);
|
||||
-- picked entries insert [title](/YYYY/MM/DD/slug) or
|
||||
-- [text](url) at the cursor — modals.allium InsertPostLinkModal
|
||||
}
|
||||
|
||||
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: 
|
||||
-- Non-images: [originalName](/media/YYYY/MM/file)
|
||||
ensures: InsertMediaModalOpened(post_id)
|
||||
-- media search grid with bds-thumb:// thumbnails; picked items
|
||||
-- insert  for images or
|
||||
-- [originalName](/media/YYYY/MM/file) otherwise —
|
||||
-- modals.allium InsertMediaModal
|
||||
}
|
||||
|
||||
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
|
||||
ensures: GalleryOverlayOpened(post_id)
|
||||
-- grid of all media linked to this post; clicking an image
|
||||
-- opens the lightbox (full-size preview, arrow navigation,
|
||||
-- ESC to close) — modals.allium GalleryOverlay / LightboxView
|
||||
}
|
||||
|
||||
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: 
|
||||
-- 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.
|
||||
ensures: ImageFileDroppedOnPostEditor(post_id, file_path)
|
||||
-- full chain: action_patterns.allium PostDragDropImageChain
|
||||
-- (synchronous import + thumbnails + link + cursor insert,
|
||||
-- background AI auto-apply and translation cascade)
|
||||
}
|
||||
|
||||
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
|
||||
let target = post/Post{id: post_id}
|
||||
requires: active_endpoint_configured
|
||||
let detected = ai_detect_language(post_suggestion_input(target))
|
||||
-- title model over the content sample; no modal
|
||||
ensures: target.language = detected
|
||||
ensures: PostAutoSaveTimerReset(post_id)
|
||||
}
|
||||
|
||||
@@ -70,40 +70,66 @@ surface ScriptEditorSurface {
|
||||
|
||||
rule ScriptSave {
|
||||
when: ScriptSaveRequested(script_id)
|
||||
-- Lua syntax check first using the configured 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 Lua source after save
|
||||
let target = script/Script{id: script_id}
|
||||
let syntax = ValidateScript(editor_content(script_id))
|
||||
-- editor_content: the current code editor buffer
|
||||
ensures:
|
||||
if syntax = valid:
|
||||
UpdateScriptRequested(target, editor_field_changes(script_id))
|
||||
-- Version bump + DB write + published-file rewrite:
|
||||
-- script.allium UpdateScript. The entrypoint select is
|
||||
-- re-populated from functions discovered in the Lua source.
|
||||
else:
|
||||
ScriptSyntaxErrorsShown(script_id, syntax)
|
||||
-- Save blocked; errors shown inline in the editor gutter
|
||||
}
|
||||
|
||||
rule ScriptPublish {
|
||||
when: ScriptPublishRequested(script_id)
|
||||
-- Only offered while the script is a draft (can_publish gate on the button)
|
||||
-- Validates Lua syntax first (publish gate); invalid source blocks publish
|
||||
-- Saves current draft fields, then publishes (status -> published)
|
||||
-- Writes the published script file to disk
|
||||
-- See script.allium PublishScript
|
||||
let target = script/Script{id: script_id}
|
||||
requires: target.status = draft
|
||||
-- can_publish gate on the button
|
||||
let syntax = ValidateScript(editor_content(script_id))
|
||||
ensures:
|
||||
if syntax = valid:
|
||||
UpdateScriptRequested(target, editor_field_changes(script_id))
|
||||
PublishScriptRequested(target)
|
||||
-- status -> published + file write: script.allium PublishScript
|
||||
else:
|
||||
ScriptSyntaxErrorsShown(script_id, syntax)
|
||||
}
|
||||
|
||||
rule ScriptCheckSyntax {
|
||||
when: ScriptCheckSyntaxRequested(script_id)
|
||||
-- Validates Lua syntax without saving
|
||||
-- Shows errors inline in editor gutter
|
||||
-- Success shown as toast or status indicator
|
||||
-- Validation only, never saves
|
||||
let syntax = ValidateScript(editor_content(script_id))
|
||||
ensures:
|
||||
if syntax = valid:
|
||||
ScriptSyntaxOkShown(script_id)
|
||||
-- toast / status indicator
|
||||
else:
|
||||
ScriptSyntaxErrorsShown(script_id, syntax)
|
||||
}
|
||||
|
||||
rule ScriptRun {
|
||||
when: ScriptRunRequested(script_id)
|
||||
-- Executes the script in the configured Lua runtime
|
||||
-- Calls the configured Lua entrypoint function
|
||||
-- stdout/stderr directed to Output panel tab
|
||||
-- Output panel auto-opens if not already visible
|
||||
-- Errors shown in Output panel with line numbers
|
||||
let target = script/Script{id: script_id}
|
||||
ensures: ExecuteScriptRequested(target, editor_entrypoint(script_id),
|
||||
empty_args(), output_panel_sink())
|
||||
-- Runs the current buffer's entrypoint in the sandboxed Lua
|
||||
-- runtime (script.allium ScriptRuntimeSurface)
|
||||
ensures: OutputPanelOpened()
|
||||
-- stdout/stderr and errors (with line numbers) stream to the
|
||||
-- Output panel tab, which auto-opens when not visible
|
||||
}
|
||||
|
||||
rule ScriptDelete {
|
||||
when: ScriptDeleteRequested(script_id)
|
||||
-- No confirmation dialog
|
||||
-- Deletes DB record + published script file on disk
|
||||
-- Closes script tab, sidebar removes item
|
||||
let target = script/Script{id: script_id}
|
||||
-- No confirmation dialog (action_patterns.allium
|
||||
-- confirmation_assignments: script_delete -> none)
|
||||
ensures: DeleteScriptRequested(target)
|
||||
-- DB record + published script file: script.allium DeleteScript
|
||||
ensures: closeTab(script_id)
|
||||
-- sidebar removes the item reactively
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ surface TemplateEditorSurface {
|
||||
when editor.can_publish
|
||||
TemplateValidateRequested(editor.template_id)
|
||||
TemplateDeleteRequested(editor.template_id)
|
||||
TemplateForceDeleteConfirmed(editor.template_id)
|
||||
|
||||
@guarantee HeaderLayout
|
||||
-- Header bar with template title tab.
|
||||
@@ -65,36 +66,75 @@ surface TemplateEditorSurface {
|
||||
|
||||
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
|
||||
let target = template/Template{id: template_id}
|
||||
let validation = ValidateLiquid(editor_content(template_id))
|
||||
-- editor_content: the current code editor buffer
|
||||
ensures:
|
||||
if validation = valid:
|
||||
UpdateTemplateRequested(target, editor_field_changes(template_id))
|
||||
-- Version bump + DB write + .liquid rewrite:
|
||||
-- template.allium UpdateTemplate,
|
||||
-- engine_side_effects.allium UpdateTemplateSideEffects
|
||||
else:
|
||||
TemplateValidationErrorsShown(template_id, validation)
|
||||
-- Save blocked; errors shown inline in the editor
|
||||
}
|
||||
|
||||
rule TemplatePublish {
|
||||
when: TemplatePublishRequested(template_id)
|
||||
-- Only offered while the template is a draft (can_publish gate on the button)
|
||||
-- Validates Liquid first (publish gate); invalid source blocks publish
|
||||
-- Saves current draft fields, then publishes (status -> published)
|
||||
-- Writes the published .liquid file to disk
|
||||
-- See template.allium PublishTemplate
|
||||
let target = template/Template{id: template_id}
|
||||
requires: target.status = draft
|
||||
-- can_publish gate on the button
|
||||
let validation = ValidateLiquid(editor_content(template_id))
|
||||
ensures:
|
||||
if validation = valid:
|
||||
UpdateTemplateRequested(target, editor_field_changes(template_id))
|
||||
PublishTemplateRequested(target)
|
||||
-- status -> published + .liquid file write:
|
||||
-- template.allium PublishTemplate
|
||||
else:
|
||||
TemplateValidationErrorsShown(template_id, validation)
|
||||
}
|
||||
|
||||
rule TemplateValidate {
|
||||
when: TemplateValidateRequested(template_id)
|
||||
-- Validates Liquid syntax without saving
|
||||
-- Shows errors inline in editor
|
||||
-- Success shown as toast or status indicator
|
||||
-- Validation only, never saves
|
||||
let validation = ValidateLiquid(editor_content(template_id))
|
||||
ensures:
|
||||
if validation = valid:
|
||||
TemplateValidationOkShown(template_id)
|
||||
-- toast / status indicator
|
||||
else:
|
||||
TemplateValidationErrorsShown(template_id, validation)
|
||||
}
|
||||
|
||||
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
|
||||
let target = template/Template{id: template_id}
|
||||
let references = target.referencing_posts.count + target.referencing_tags.count
|
||||
ensures:
|
||||
if references > 0:
|
||||
TemplateForceDeleteConfirmShown(template_id)
|
||||
-- system confirm dialog: "This template is used by N posts
|
||||
-- and M tags. Force delete?" (action_patterns.allium
|
||||
-- confirmation_assignments: template_delete_with_references)
|
||||
else:
|
||||
DeleteTemplateRequested(target)
|
||||
closeTab(template_id)
|
||||
-- no confirmation when nothing references the template
|
||||
}
|
||||
|
||||
rule TemplateForceDelete {
|
||||
when: TemplateForceDeleteConfirmed(template_id)
|
||||
let target = template/Template{id: template_id}
|
||||
-- Clear references first so template.allium DeleteTemplate's
|
||||
-- zero-reference precondition holds:
|
||||
for p in target.referencing_posts:
|
||||
ensures: p.template_slug = null
|
||||
for t in target.referencing_tags:
|
||||
ensures: t.post_template_slug = null
|
||||
ensures: DeleteTemplateRequested(target)
|
||||
-- DB record + .liquid file on disk
|
||||
ensures: closeTab(template_id)
|
||||
-- sidebar removes the item reactively
|
||||
}
|
||||
|
||||
@@ -21,34 +21,85 @@ config {
|
||||
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
|
||||
-- ─── Split localization ─────────────────────────────────────
|
||||
--
|
||||
-- Two independent locale scopes exist per running app. They are
|
||||
-- resolved by separate rules from separate inputs and may differ.
|
||||
|
||||
entity LocaleScopes {
|
||||
-- Singleton holding the two locale scopes.
|
||||
ui_locale: String
|
||||
-- Follows the OS system locale; used for menus and app chrome
|
||||
render_locale: String
|
||||
-- Follows the active project's mainLanguage; used for
|
||||
-- template rendering and generated content
|
||||
}
|
||||
|
||||
invariant LanguageNormalization {
|
||||
-- Input language codes are normalized:
|
||||
-- Take base language code (split on '-'): "en-US" -> "en"
|
||||
-- Fall back to "en" if unrecognized
|
||||
surface LocaleSurface {
|
||||
facing _: LocaleConsumer
|
||||
|
||||
provides:
|
||||
OsLocaleDetected(os_locale)
|
||||
ProjectMainLanguageResolved(main_language)
|
||||
MenuTranslationRequested(item_key)
|
||||
RenderTranslationRequested(label_key)
|
||||
}
|
||||
|
||||
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
|
||||
-- ─── Language normalization ─────────────────────────────────
|
||||
--
|
||||
-- Both scopes normalize their input the same way: take the base
|
||||
-- language code, fall back to the default when unsupported.
|
||||
|
||||
rule NormalizeUiLocale {
|
||||
when: OsLocaleDetected(os_locale)
|
||||
let base = base_language_code(os_locale)
|
||||
-- black box: substring of the input before the first "-"
|
||||
-- ("en-US" -> "en")
|
||||
let codes = config.supported_languages -> code
|
||||
ensures:
|
||||
if base in codes:
|
||||
LocaleScopes.ui_locale = base
|
||||
else:
|
||||
LocaleScopes.ui_locale = config.default_language
|
||||
}
|
||||
|
||||
invariant RenderTranslations {
|
||||
rule NormalizeRenderLocale {
|
||||
when: ProjectMainLanguageResolved(main_language)
|
||||
let base = base_language_code(main_language)
|
||||
let codes = config.supported_languages -> code
|
||||
ensures:
|
||||
if base in codes:
|
||||
LocaleScopes.render_locale = base
|
||||
else:
|
||||
LocaleScopes.render_locale = config.default_language
|
||||
}
|
||||
|
||||
-- ─── Translation lookup scopes ──────────────────────────────
|
||||
|
||||
rule TranslateMenuLabel {
|
||||
when: MenuTranslationRequested(item_key)
|
||||
-- Menus follow the OS convention: labels match the system
|
||||
-- language (UI locale), never the render locale.
|
||||
ensures: MenuLabelTranslated(item_key, locale: LocaleScopes.ui_locale)
|
||||
}
|
||||
|
||||
rule TranslateRenderLabel {
|
||||
when: RenderTranslationRequested(label_key)
|
||||
-- Template rendering i18n strings (date formats, archive labels,
|
||||
-- "older posts", "newer posts", etc.) come from locale JSON files
|
||||
-- translateRender() and getRenderTranslations() provide these
|
||||
-- "older posts", "newer posts", etc.) come from the locale JSON
|
||||
-- files for the render locale.
|
||||
ensures: RenderLabelTranslated(label_key, locale: LocaleScopes.render_locale)
|
||||
}
|
||||
|
||||
-- Stemmer language support for search (broader than UI languages)
|
||||
-- ─── Search stemmer coverage ────────────────────────────────
|
||||
|
||||
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
|
||||
-- ISO 639-1 codes with a dedicated Snowball algorithm
|
||||
-- (stemmer_for_language in bds-core fts.rs); nb/nn/no share the
|
||||
-- Norwegian stemmer, unknown codes fall back to English.
|
||||
-- Every UI language must have real stemming, not the fallback.
|
||||
let stemmed_codes = {"ar", "da", "de", "el", "en", "es", "fi", "fr",
|
||||
"hu", "it", "nb", "nn", "no", "nl", "pt", "ro", "ru", "sv", "ta", "tr"}
|
||||
for lang in config.supported_languages:
|
||||
lang.code in stemmed_codes
|
||||
}
|
||||
|
||||
@@ -684,52 +684,79 @@ surface MigrationVersionSurface {
|
||||
-- ============================================================================
|
||||
|
||||
invariant UniqueProjectSlug {
|
||||
-- projects.slug must be unique across all projects
|
||||
for a in Projects:
|
||||
for b in Projects:
|
||||
a != b implies a.slug != b.slug
|
||||
}
|
||||
|
||||
invariant UniquePostSlugPerProject {
|
||||
-- posts.slug must be unique within each project.project_id
|
||||
-- Enforced by: posts_project_slug_idx unique index
|
||||
for a in Posts:
|
||||
for b in Posts:
|
||||
a != b and a.project_id = b.project_id implies a.slug != b.slug
|
||||
}
|
||||
|
||||
invariant UniqueTranslationPerPostLanguage {
|
||||
-- post_translations must have unique (translation_for, language)
|
||||
-- Enforced by: post_translations_translation_language_idx
|
||||
for a in PostTranslations:
|
||||
for b in PostTranslations:
|
||||
a != b implies
|
||||
not (a.translation_for = b.translation_for and a.language = b.language)
|
||||
}
|
||||
|
||||
invariant UniqueMediaTranslationPerMediaLanguage {
|
||||
-- media_translations must have unique (translation_for, language)
|
||||
-- Enforced by: media_translations_translation_language_idx
|
||||
for a in MediaTranslations:
|
||||
for b in MediaTranslations:
|
||||
a != b implies
|
||||
not (a.translation_for = b.translation_for and a.language = b.language)
|
||||
}
|
||||
|
||||
invariant UniqueTagNamePerProject {
|
||||
-- tags.name must be unique within each project.project_id
|
||||
-- Enforced by: tags_project_name_idx unique index
|
||||
-- Case-insensitive: lower() is the SQLite collation applied by the index
|
||||
for a in Tags:
|
||||
for b in Tags:
|
||||
a != b and a.project_id = b.project_id implies lower(a.name) != lower(b.name)
|
||||
}
|
||||
|
||||
invariant UniqueScriptSlugPerProject {
|
||||
-- scripts.slug must be unique within each project.project_id
|
||||
-- Enforced by: scripts_project_slug_idx unique index
|
||||
for a in Scripts:
|
||||
for b in Scripts:
|
||||
a != b and a.project_id = b.project_id implies a.slug != b.slug
|
||||
}
|
||||
|
||||
invariant UniqueTemplateSlugPerProject {
|
||||
-- templates.slug must be unique within each project.project_id
|
||||
-- Enforced by: templates_project_slug_idx unique index
|
||||
for a in Templates:
|
||||
for b in Templates:
|
||||
a != b and a.project_id = b.project_id implies a.slug != b.slug
|
||||
}
|
||||
|
||||
invariant UniquePostMediaLink {
|
||||
-- post_media must have unique (post_id, media_id) pair
|
||||
-- Enforced by: post_media_post_media_idx unique index
|
||||
for a in PostMediaLinks:
|
||||
for b in PostMediaLinks:
|
||||
a != b implies not (a.post_id = b.post_id and a.media_id = b.media_id)
|
||||
}
|
||||
|
||||
invariant UniqueGeneratedFileHash {
|
||||
-- generated_file_hashes must have unique (project_id, relative_path)
|
||||
-- Enforced by: generated_file_hashes_project_path_idx unique index
|
||||
for a in GeneratedFileHashes:
|
||||
for b in GeneratedFileHashes:
|
||||
a != b implies
|
||||
not (a.project_id = b.project_id and a.relative_path = b.relative_path)
|
||||
}
|
||||
|
||||
invariant UniqueDismissedDuplicatePair {
|
||||
-- dismissed_duplicate_pairs must have unique (project_id, post_id_a, post_id_b)
|
||||
-- Enforced by: dismissed_pairs_idx unique index
|
||||
for a in DismissedDuplicatePairs:
|
||||
for b in DismissedDuplicatePairs:
|
||||
a != b implies
|
||||
not (a.project_id = b.project_id
|
||||
and a.post_id_a = b.post_id_a
|
||||
and a.post_id_b = b.post_id_b)
|
||||
}
|
||||
|
||||
-- ============================================================================
|
||||
|
||||
@@ -194,75 +194,81 @@ rule RebuildTemplatesFromFiles {
|
||||
ensures: not exists template
|
||||
}
|
||||
|
||||
-- Exact Liquid subset required (distilled from bundled starter templates)
|
||||
-- No features beyond this list are used.
|
||||
-- ─── Liquid subset (compatibility contract) ─────────────────
|
||||
--
|
||||
-- Exact Liquid subset required (distilled from bundled starter templates).
|
||||
-- No features beyond these sets are accepted. Enforcement happens at
|
||||
-- publish/validation time (ValidateLiquid); anything outside the sets is
|
||||
-- rejected even though Liquex would otherwise support it as a built-in.
|
||||
|
||||
config {
|
||||
-- The 5 allowed tag families as their concrete tag names.
|
||||
-- Whitespace-stripped variants ({%- -%}) of each are also accepted.
|
||||
-- render takes named parameters: {% render 'partial', param: value %}.
|
||||
-- NOT allowed: include, capture, case/when, unless, raw, comment,
|
||||
-- cycle, tablerow, increment, decrement, liquid, echo
|
||||
liquid_allowed_tags: Set<String> = {
|
||||
"if", "elsif", "else", "endif",
|
||||
"for", "endfor",
|
||||
"assign",
|
||||
"render"
|
||||
}
|
||||
|
||||
-- Standard filters (4).
|
||||
-- default takes a fallback value, append a suffix string.
|
||||
-- NOT allowed: 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
|
||||
liquid_allowed_standard_filters: Set<String> = {
|
||||
"escape", "url_encode", "default", "append"
|
||||
}
|
||||
|
||||
-- Custom filters (3):
|
||||
-- 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)
|
||||
-- slugify — slugifies a string (tag/category URLs)
|
||||
liquid_allowed_custom_filters: Set<String> = {
|
||||
"i18n", "markdown", "slugify"
|
||||
}
|
||||
|
||||
-- NOT allowed: !=, <, >=, <=, contains.
|
||||
-- Logical operators or/and, bare-variable truthiness in {% if %},
|
||||
-- the special value blank, dot property access, .size on arrays,
|
||||
-- and bracket map lookups are part of expression syntax, not
|
||||
-- operator validation.
|
||||
liquid_allowed_comparison_operators: Set<String> = { "==", ">" }
|
||||
}
|
||||
|
||||
-- A published template has passed ValidateLiquid, so its content only
|
||||
-- uses the allowed sets. liquid_tags / liquid_filters /
|
||||
-- liquid_comparison_operators are black boxes extracting the construct
|
||||
-- names a template uses.
|
||||
|
||||
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
|
||||
for t in Templates:
|
||||
t.status = published implies
|
||||
for tag in liquid_tags(effective_template_content(t)):
|
||||
tag in config.liquid_allowed_tags
|
||||
}
|
||||
|
||||
invariant LiquidFilterSubset {
|
||||
-- Standard filters (4):
|
||||
-- | escape
|
||||
-- | url_encode
|
||||
-- | default: fallback_value
|
||||
-- | append: suffix_string
|
||||
--
|
||||
-- Custom filters (3):
|
||||
-- | 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)
|
||||
-- | slugify — slugifies a string (used for tag/category URLs)
|
||||
--
|
||||
-- Enforced at publish/validation time (LiquidParser.validate); any other
|
||||
-- filter is rejected even though Liquex would otherwise apply it as a
|
||||
-- built-in standard filter.
|
||||
--
|
||||
-- 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
|
||||
let allowed = config.liquid_allowed_standard_filters
|
||||
+ config.liquid_allowed_custom_filters
|
||||
for t in Templates:
|
||||
t.status = published implies
|
||||
for f in liquid_filters(effective_template_content(t)):
|
||||
f in allowed
|
||||
}
|
||||
|
||||
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])
|
||||
--
|
||||
-- Enforced at publish/validation time (LiquidParser.validate); any other
|
||||
-- comparison operator (!=, <, >=, <=, contains) is rejected even though
|
||||
-- Liquex would otherwise evaluate it.
|
||||
for t in Templates:
|
||||
t.status = published implies
|
||||
for op in liquid_comparison_operators(effective_template_content(t)):
|
||||
op in config.liquid_allowed_comparison_operators
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
-- The top-level variables available to templates at render time are
|
||||
-- formalized as the RenderContext value records in template_context.allium.
|
||||
|
||||
530
specs/tui.allium
530
specs/tui.allium
@@ -8,11 +8,22 @@ entity TuiState {
|
||||
focus: sidebar | editor
|
||||
selected_index: Integer
|
||||
editing_post: Boolean
|
||||
editor_preview: Boolean
|
||||
-- true while the read-only rendered-Markdown preview is shown
|
||||
-- The editor drives the same BDS.UI.PostEditor workflow as the GUI:
|
||||
-- canonical-language edits update the post, other languages update
|
||||
-- translations; publish routes through the same publishing pipeline.
|
||||
}
|
||||
|
||||
config {
|
||||
-- Same preference sections as the settings backends; matches the
|
||||
-- GUI settings nav minus the GUI-only Style tab.
|
||||
tui_settings_sections: List<String> = [
|
||||
"project", "editor", "content", "ai",
|
||||
"technology", "publishing", "data", "mcp"
|
||||
]
|
||||
}
|
||||
|
||||
surface TuiSurface {
|
||||
facing _: TuiRuntime
|
||||
|
||||
@@ -21,207 +32,468 @@ surface TuiSurface {
|
||||
TuiEventReceived(entity, entity_id, action)
|
||||
TuiSettingsChanged(key)
|
||||
ShellTaskCompleted(route)
|
||||
ProjectsOverlaySelectionConfirmed(project)
|
||||
FolderPathPromptTabPressed(input)
|
||||
FolderPathPromptSubmitted(path)
|
||||
SearchPromptChanged(query)
|
||||
SearchPromptConfirmed(query)
|
||||
SearchPromptCancelled()
|
||||
CommandListSelectionConfirmed(command)
|
||||
GitCommitPromptSubmitted(message)
|
||||
|
||||
@guarantee OverlayPrecedence
|
||||
-- Open overlays and status-line prompts capture key input
|
||||
-- before the sidebar/editor key rules below apply. Overlays
|
||||
-- clear the terminal cells beneath them.
|
||||
|
||||
@guarantee EditorRendering
|
||||
-- The post editor renders the buffer with syntax highlighting
|
||||
-- (markdown with [[macro]] accents for posts, HTML+Liquid for
|
||||
-- templates, Lua for scripts), soft-wraps long lines to the
|
||||
-- viewport width, keeps the cursor's visual row visible while
|
||||
-- scrolling, and highlights the cursor's source line. There is
|
||||
-- no line-number gutter.
|
||||
}
|
||||
|
||||
-- ─── Sidebar navigation ─────────────────────────────────────
|
||||
|
||||
rule SidebarNavigation {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = "up" or code = "down" or code = "j" or code = "k"
|
||||
-- Selection moves over the flattened sidebar items and skips
|
||||
-- section headers; data comes from BDS.UI.Sidebar.view, identical
|
||||
-- to the GUI sidebar content.
|
||||
ensures: TuiState.selected_index.updated()
|
||||
requires: TuiState.focus = sidebar
|
||||
let items = flattened_sidebar_items()
|
||||
-- BDS.UI.Sidebar.view data, identical to the GUI sidebar
|
||||
ensures:
|
||||
if code = "up" or code = "k":
|
||||
TuiState.selected_index = previous_selectable_index(items, TuiState.selected_index)
|
||||
else:
|
||||
TuiState.selected_index = next_selectable_index(items, TuiState.selected_index)
|
||||
-- section headers are not selectable; movement skips them
|
||||
}
|
||||
|
||||
rule OpenEntry {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = "enter"
|
||||
-- Posts open in the editor by default (title + textarea seeded from
|
||||
-- the persisted form; syntax highlighting and word wrap on; ctrl+e
|
||||
-- switches to the rendered preview). New empty posts created with
|
||||
-- "n" open in the editor as well. Images open in the terminal image
|
||||
-- preview. Other entities report that terminal editing is not
|
||||
-- available yet.
|
||||
ensures: TuiState.editing_post.updated()
|
||||
requires: TuiState.focus = sidebar
|
||||
let entry = selected_sidebar_entry(TuiState.selected_index)
|
||||
ensures:
|
||||
if entry.kind = "post":
|
||||
TuiState.editing_post = true
|
||||
TuiState.focus = editor
|
||||
-- editor seeded from the persisted form (title + textarea);
|
||||
-- syntax highlighting and word wrap on. New empty posts
|
||||
-- created with "n" open in the editor the same way.
|
||||
if entry.kind = "image":
|
||||
TerminalImagePreviewOpened(entry)
|
||||
if entry.kind != "post" and entry.kind != "image":
|
||||
StatusMessageShown(message_key: "tui.editing_not_available")
|
||||
}
|
||||
|
||||
-- ─── Post editor keys ───────────────────────────────────────
|
||||
|
||||
rule SaveAndPublish {
|
||||
when: TuiKeyPressed(code, modifiers)
|
||||
requires: code = "s" or code = "p"
|
||||
requires: modifiers = "ctrl"
|
||||
-- ctrl+s saves the draft, ctrl+p saves and publishes — both through
|
||||
-- BDS.UI.PostEditor.Persistence, so files, embeddings, search, and
|
||||
-- the event bus behave exactly as a GUI save.
|
||||
ensures: PostPersisted()
|
||||
requires: TuiState.editing_post
|
||||
ensures:
|
||||
if code = "s":
|
||||
PostDraftPersisted()
|
||||
else:
|
||||
PostPersistedAndPublished()
|
||||
-- Both persist through BDS.UI.PostEditor.Persistence, so files,
|
||||
-- embeddings, search index, and the event bus behave exactly as a
|
||||
-- GUI save.
|
||||
}
|
||||
|
||||
rule EditorMode {
|
||||
when: TuiKeyPressed(code, modifiers)
|
||||
requires: code = "e"
|
||||
requires: modifiers = "ctrl"
|
||||
-- ctrl+e toggles between the syntax-highlighted editor (the default
|
||||
-- mode when opening a post) and the read-only rendered-Markdown
|
||||
-- preview (rendered through tui-markdown so headings/emphasis/lists
|
||||
-- are styled); keys in preview never edit content, and esc returns
|
||||
-- to the sidebar from either mode.
|
||||
ensures: PreviewToggled()
|
||||
requires: TuiState.editing_post
|
||||
ensures: TuiState.editor_preview = not TuiState.editor_preview
|
||||
-- toggles between the syntax-highlighted editor (the default
|
||||
-- when opening a post) and the read-only rendered-Markdown
|
||||
-- preview (tui-markdown: headings/emphasis/lists styled);
|
||||
-- keys in preview never edit content
|
||||
}
|
||||
|
||||
rule CodeEditorWrapping {
|
||||
when: TuiState.editing_post.updated()
|
||||
-- The post editor renders the buffer with syntax highlighting
|
||||
-- (markdown with [[macro]] accents for posts, HTML+Liquid for
|
||||
-- templates, Lua for scripts), soft-wraps long lines to the
|
||||
-- viewport width, keeps the cursor's visual row visible while
|
||||
-- scrolling, and highlights the cursor's source line. There is no
|
||||
-- line-number gutter.
|
||||
ensures: TuiState.updated()
|
||||
rule EditorEscape {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = "esc"
|
||||
requires: TuiState.focus = editor
|
||||
ensures: TuiState.focus = sidebar
|
||||
-- esc returns to the sidebar from either editor mode
|
||||
}
|
||||
|
||||
rule AiQuickAction {
|
||||
when: TuiKeyPressed(code, modifiers)
|
||||
requires: code = "g"
|
||||
requires: modifiers = "ctrl"
|
||||
-- One-shot AI post analysis (title/excerpt suggestions). Gated by
|
||||
-- airplane mode: without a local endpoint the TUI shows a status
|
||||
-- message instead of calling out.
|
||||
ensures: AiSuggestionsRequestedOrRefused()
|
||||
requires: TuiState.editing_post
|
||||
ensures:
|
||||
if active_endpoint_configured:
|
||||
AISuggestionRequested(entity_type: "post", entity_id: edited_post_id())
|
||||
-- one-shot AI post analysis (title/excerpt suggestions)
|
||||
else:
|
||||
StatusMessageShown(message_key: "tui.ai_unavailable_offline")
|
||||
-- airplane gating: without a local endpoint the TUI shows
|
||||
-- a status message instead of calling out
|
||||
}
|
||||
|
||||
-- ─── Project switcher ───────────────────────────────────────
|
||||
|
||||
rule ProjectSwitcher {
|
||||
when: TuiKeyPressed(code, _)
|
||||
when: TuiKeyPressed(code, modifiers)
|
||||
requires: code = "p"
|
||||
-- "p" opens the projects overlay: all projects from the caching
|
||||
-- database with the active one marked; enter activates the selected
|
||||
-- project (BDS.Projects.set_active_project) and reloads the sidebar.
|
||||
-- "o" switches to a folder-path prompt with bash-style tab
|
||||
-- completion: tab completes a unique directory (trailing slash
|
||||
-- appended), an ambiguous prefix completes to the longest common
|
||||
-- prefix and lists the candidate folders; only directories are
|
||||
-- offered, dotfolders only for a dot-prefixed input, and a leading
|
||||
-- "~" expands to the home directory. A typed path is validated
|
||||
-- to be an existing directory, then opened as a project
|
||||
-- (BDS.Projects.create_project with data_path, named after the
|
||||
-- folder) and activated; a full database rebuild is queued
|
||||
-- automatically through the rebuild_database shell command so the
|
||||
-- folder's content is loaded into the caching database. Invalid
|
||||
-- paths keep the prompt open and report the error.
|
||||
ensures: ProjectSwitchedOrOpened()
|
||||
requires: modifiers != "ctrl"
|
||||
requires: TuiState.focus = sidebar
|
||||
ensures: ProjectsOverlayOpened(projects: all_projects(), active: active_project())
|
||||
-- all projects from the caching database, active one marked
|
||||
}
|
||||
|
||||
rule ProjectOverlayActivate {
|
||||
when: ProjectsOverlaySelectionConfirmed(project)
|
||||
ensures: ActiveProjectChanged(project)
|
||||
-- BDS.Projects.set_active_project
|
||||
ensures: SidebarReloaded()
|
||||
}
|
||||
|
||||
rule ProjectOverlayFolderPrompt {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = "o"
|
||||
requires: projects_overlay_open()
|
||||
ensures: FolderPathPromptOpened()
|
||||
-- switches the overlay to a folder-path prompt with bash-style
|
||||
-- tab completion
|
||||
}
|
||||
|
||||
rule FolderPromptTabCompletion {
|
||||
when: FolderPathPromptTabPressed(input)
|
||||
let expanded = expand_home(input)
|
||||
-- a leading "~" expands to the home directory
|
||||
let candidates = completion_candidates(expanded)
|
||||
-- only directories are offered; dotfolders only when the last
|
||||
-- path segment starts with "."
|
||||
ensures:
|
||||
if candidates.count = 1:
|
||||
FolderPromptInputReplaced(candidates.first + "/")
|
||||
-- a unique directory completes with a trailing slash
|
||||
if candidates.count > 1:
|
||||
FolderPromptInputReplaced(longest_common_prefix(candidates))
|
||||
FolderPromptCandidatesListed(candidates)
|
||||
-- ambiguous prefix: longest common prefix + candidate list
|
||||
if candidates.count = 0:
|
||||
FolderPromptUnchanged()
|
||||
}
|
||||
|
||||
rule FolderPromptSubmit {
|
||||
when: FolderPathPromptSubmitted(path)
|
||||
let expanded = expand_home(path)
|
||||
ensures:
|
||||
if directory_exists(expanded):
|
||||
ProjectOpenedFromFolder(expanded)
|
||||
-- BDS.Projects.create_project with data_path, named after
|
||||
-- the folder, then activated
|
||||
DatabaseRebuildQueued(expanded)
|
||||
-- queued automatically through the rebuild_database shell
|
||||
-- command so the folder's content is loaded into the
|
||||
-- caching database
|
||||
else:
|
||||
FolderPromptErrorShown(path)
|
||||
-- invalid paths keep the prompt open and report the error
|
||||
}
|
||||
|
||||
-- ─── Sidebar search ─────────────────────────────────────────
|
||||
|
||||
enum TuiSearchTokenKind {
|
||||
text
|
||||
-- plain word: text search
|
||||
tag_filter
|
||||
-- "tag:x": filters like the GUI tag chip
|
||||
category_filter
|
||||
-- "category:x": filters like the GUI category chip
|
||||
day_filter
|
||||
-- ISO date (yyyy-mm-dd): narrows to that day; extends the
|
||||
-- shared sidebar queries
|
||||
}
|
||||
|
||||
value TuiSearchToken {
|
||||
kind: TuiSearchTokenKind
|
||||
argument: String
|
||||
-- the search word, tag/category name, or ISO date
|
||||
}
|
||||
|
||||
surface TuiSearchTokenSurface {
|
||||
context token: TuiSearchToken
|
||||
|
||||
exposes:
|
||||
token.kind
|
||||
token.argument
|
||||
}
|
||||
|
||||
rule SidebarSearch {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = "/"
|
||||
-- "/" in sidebar focus opens a vi-style search prompt in the status
|
||||
-- line that live-filters the current view through the same
|
||||
-- BDS.UI.Sidebar filter params the GUI sidebar uses (posts, pages,
|
||||
-- media). Plain words are a text search, "tag:x" and "category:x"
|
||||
-- filter like the GUI chips, and an ISO date (yyyy-mm-dd) narrows
|
||||
-- to that day — the day filter extends the shared sidebar queries.
|
||||
-- Tokens combine with AND. Enter keeps the filter (shown appended
|
||||
-- to the sidebar title), esc clears it; filters are per view.
|
||||
ensures: SidebarFiltered()
|
||||
requires: TuiState.focus = sidebar
|
||||
ensures: SearchPromptOpened()
|
||||
-- vi-style search prompt in the status line
|
||||
}
|
||||
|
||||
rule SidebarSearchInput {
|
||||
when: SearchPromptChanged(query)
|
||||
let tokens = parse_search_tokens(query)
|
||||
-- black box: each whitespace-separated token classified as a
|
||||
-- TuiSearchToken; tokens combine with AND
|
||||
ensures: SidebarFiltered(filters: tokens)
|
||||
-- live-filters the current view through the same shared
|
||||
-- BDS.UI.Sidebar filter params the GUI sidebar uses
|
||||
-- (posts, pages, media)
|
||||
}
|
||||
|
||||
rule SidebarSearchCommit {
|
||||
when: SearchPromptConfirmed(query)
|
||||
ensures: SidebarFilterKept(view: TuiState.view, query: query)
|
||||
-- enter keeps the filter, shown appended to the sidebar title;
|
||||
-- filters are per view
|
||||
}
|
||||
|
||||
rule SidebarSearchCancel {
|
||||
when: SearchPromptCancelled()
|
||||
ensures: SidebarFilterCleared(view: TuiState.view)
|
||||
-- esc clears the filter
|
||||
}
|
||||
|
||||
-- ─── Command prompt ─────────────────────────────────────────
|
||||
|
||||
rule CommandPrompt {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = ":"
|
||||
-- Vi-style prompt: ":" opens a filterable list of the parameterless
|
||||
-- Blog-menu shell commands (metadata diff, validate site, force
|
||||
-- render, rebuilds, reindex, translations, duplicates, upload,
|
||||
-- browser preview URL) and runs the selection through the same
|
||||
-- BDS.Desktop.ShellCommands backend as the GUI menu; typing ":?"
|
||||
-- shows the full list as help. Overlays clear the cells beneath
|
||||
-- them. Commands whose follow-up UI is GUI-only (validate
|
||||
-- translations, find duplicates) are marked. Queued tasks report
|
||||
-- progress in the status line until all tasks finish.
|
||||
ensures: CommandListShownOrExecuted()
|
||||
requires: TuiState.focus = sidebar
|
||||
ensures: CommandListOpened(commands: parameterless_shell_commands())
|
||||
-- vi-style prompt: filterable list of the parameterless
|
||||
-- Blog-menu shell commands (metadata diff, validate site,
|
||||
-- force render, rebuilds, reindex, translations, duplicates,
|
||||
-- upload, browser preview URL) — the same
|
||||
-- BDS.Desktop.ShellCommands backend as the GUI menu. ":?"
|
||||
-- shows the full list as help. Commands whose follow-up UI is
|
||||
-- GUI-only (validate translations, find duplicates) are marked.
|
||||
}
|
||||
|
||||
rule CommandPromptExecute {
|
||||
when: CommandListSelectionConfirmed(command)
|
||||
ensures: ShellCommandExecuted(command)
|
||||
ensures: TaskProgressShownInStatusLine()
|
||||
-- queued tasks report progress in the status line until all
|
||||
-- tasks finish
|
||||
}
|
||||
|
||||
-- ─── Settings panel (issue #29) ─────────────────────────────
|
||||
|
||||
rule SettingsPanel {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = "6"
|
||||
-- "6" opens the settings panel (issue #29), matching the GUI panel
|
||||
-- numbering: the sidebar lists the same preference sections as the
|
||||
-- settings backends (Project, Editor, Content, AI, Technology,
|
||||
-- Publishing, Data, MCP). The GUI-only Style tab is intentionally
|
||||
-- absent. Enter on a section opens a section-specific editor in the
|
||||
-- main area: a generic typed-field form from BDS.UI.SettingsForm.
|
||||
-- Enter edits a text field in a status-line prompt, toggles a
|
||||
-- boolean, or cycles an enum through its options; read-only info
|
||||
-- rows are skipped by the selection. ctrl+s saves the section
|
||||
-- through the same backends as the GUI editor (BDS.Metadata,
|
||||
-- BDS.Settings, BDS.AI, BDS.MCP.AgentConfig) and reloads the form;
|
||||
-- esc cancels the prompt, then closes the form. Category removal
|
||||
-- and AI model discovery remain GUI-only.
|
||||
ensures: SettingsFormShownEditedOrSaved()
|
||||
requires: TuiState.focus = sidebar
|
||||
-- "6" matches the GUI panel numbering
|
||||
ensures: TuiState.view = settings
|
||||
ensures: SettingsSectionsListed(sections: config.tui_settings_sections)
|
||||
-- enter on a section opens a section-specific editor in the
|
||||
-- main area: a generic typed-field form from BDS.UI.SettingsForm
|
||||
}
|
||||
|
||||
rule SettingsFieldEdit {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = "enter"
|
||||
requires: TuiState.view = settings
|
||||
let row = selected_settings_row()
|
||||
requires: not row.read_only
|
||||
-- read-only info rows are skipped by the selection
|
||||
ensures:
|
||||
if row.kind = "text":
|
||||
StatusLinePromptOpened(row)
|
||||
-- esc cancels the prompt, then closes the form
|
||||
if row.kind = "boolean":
|
||||
SettingToggled(row)
|
||||
if row.kind = "enum":
|
||||
SettingCycled(row)
|
||||
-- cycles the enum through its options
|
||||
}
|
||||
|
||||
rule SettingsSave {
|
||||
when: TuiKeyPressed(code, modifiers)
|
||||
requires: code = "s"
|
||||
requires: modifiers = "ctrl"
|
||||
requires: TuiState.view = settings
|
||||
ensures: SettingsSectionSaved(selected_settings_section())
|
||||
-- saves through the same backends as the GUI editor
|
||||
-- (BDS.Metadata, BDS.Settings, BDS.AI, BDS.MCP.AgentConfig)
|
||||
-- and reloads the form. Category removal and AI model
|
||||
-- discovery remain GUI-only.
|
||||
}
|
||||
|
||||
-- ─── Tags panel (issue #34) ─────────────────────────────────
|
||||
|
||||
rule TagsPanel {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = "5"
|
||||
-- "5" opens the tags view (issue #34), matching the GUI panel
|
||||
-- numbering. The sidebar lists the same three sections as the GUI
|
||||
-- tags editor (Tag Cloud, Create / Edit, Merge Tags — from
|
||||
-- BDS.UI.Sidebar's tags nav view); enter opens the section in the
|
||||
-- main area, all backed by BDS.UI.TagsPanel over the same BDS.Tags
|
||||
-- operations as the GUI editor. Cloud lists tags with their post
|
||||
-- usage counts ordered by usage; manage is alphabetical with "n"
|
||||
-- (create prompt), enter (rename prompt), "c" (cycle colour through
|
||||
-- the shared presets), "t" (cycle post template), "d" then "y"
|
||||
-- (delete with post-count confirmation; any other key cancels) and
|
||||
-- "s" (sync tags from post tags); merge marks tags with space and
|
||||
-- "m" merges the marked tags into the selected one (at least two,
|
||||
-- target included). Text prompts live in the status line; esc
|
||||
-- cancels the prompt, then closes the panel. Domain tag events
|
||||
-- reload an open panel, keeping it in sync with GUI and CLI writes.
|
||||
ensures: TagsPanelShownOrEdited()
|
||||
requires: TuiState.focus = sidebar
|
||||
-- "5" matches the GUI panel numbering
|
||||
ensures: TuiState.view = tags
|
||||
ensures: TagsSectionsListed()
|
||||
-- the same three sections as the GUI tags editor (Tag Cloud,
|
||||
-- Create / Edit, Merge Tags — from BDS.UI.Sidebar's tags nav
|
||||
-- view); enter opens the section in the main area, all backed
|
||||
-- by BDS.UI.TagsPanel over the same BDS.Tags operations as the
|
||||
-- GUI editor. Cloud lists tags with post usage counts ordered
|
||||
-- by usage; manage is alphabetical. Text prompts live in the
|
||||
-- status line; esc cancels the prompt, then closes the panel.
|
||||
-- Domain tag events reload an open panel, keeping it in sync
|
||||
-- with GUI and CLI writes.
|
||||
}
|
||||
|
||||
rule TagsManageKeys {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: TuiState.view = tags
|
||||
requires: tags_manage_section_active()
|
||||
ensures:
|
||||
if code = "n":
|
||||
TagCreatePromptOpened()
|
||||
if code = "enter":
|
||||
TagRenamePromptOpened(selected_tag())
|
||||
if code = "c":
|
||||
TagColourCycled(selected_tag())
|
||||
-- cycles through the shared colour presets
|
||||
if code = "t":
|
||||
TagTemplateCycled(selected_tag())
|
||||
-- cycles the tag's post template
|
||||
if code = "d":
|
||||
TagDeleteConfirmArmed(selected_tag())
|
||||
-- "d" then "y" deletes with post-count confirmation;
|
||||
-- any other key cancels
|
||||
if code = "s":
|
||||
TagsSyncedFromPosts()
|
||||
}
|
||||
|
||||
rule TagsMergeKeys {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: TuiState.view = tags
|
||||
requires: tags_merge_section_active()
|
||||
ensures:
|
||||
if code = "space":
|
||||
TagMarkToggled(selected_tag())
|
||||
if code = "m" and marked_tags().count >= 2:
|
||||
TagsMerged(source_tags: marked_tags(), target_tag: selected_tag())
|
||||
-- merges the marked tags into the selected one (target
|
||||
-- included, at least two marked)
|
||||
}
|
||||
|
||||
-- ─── Git panel (issue #30) ──────────────────────────────────
|
||||
|
||||
rule GitPanel {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = "7"
|
||||
-- "7" opens the git panel (issue #30) for content sync: the sidebar
|
||||
-- lists the changed files from git status (code + path, branch with
|
||||
-- ahead/behind counts in the title) with the commit history in its
|
||||
-- lower half, like the GUI (short hash + subject; ↑ marks commits
|
||||
-- that still need a push, ↓ remote-only ones), the main area shows a
|
||||
-- scrollable whole-folder diff (staged + unstaged, capped) paged
|
||||
-- with pgup/pgdn; enter on a file jumps the diff to it. "c" opens a
|
||||
-- status-line commit prompt (git add -A + commit, empty messages
|
||||
-- rejected), "u" pulls (ff-only) and "s" pushes — both run off the
|
||||
-- UI process and report back as a status toast. Every action is
|
||||
-- BDS.Git, the same backend as the GUI git panel. A project folder
|
||||
-- that is not a git repository shows a message and refuses the
|
||||
-- actions.
|
||||
ensures: GitPanelShownOrSynced()
|
||||
requires: TuiState.focus = sidebar
|
||||
-- "7" matches the GUI panel numbering; content sync panel
|
||||
ensures: TuiState.view = git
|
||||
ensures: GitPanelOpened()
|
||||
-- sidebar: changed files from git status (code + path, branch
|
||||
-- with ahead/behind counts in the title), commit history in
|
||||
-- the lower half like the GUI (short hash + subject; ↑ marks
|
||||
-- commits that still need a push, ↓ remote-only ones).
|
||||
-- Main area: scrollable whole-folder diff (staged + unstaged,
|
||||
-- capped) paged with pgup/pgdn. Every action is BDS.Git, the
|
||||
-- same backend as the GUI git panel.
|
||||
}
|
||||
|
||||
rule GitPanelKeys {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: TuiState.view = git
|
||||
requires: is_git_repository(active_project())
|
||||
ensures:
|
||||
if code = "enter":
|
||||
GitDiffJumpedToFile(selected_changed_file())
|
||||
if code = "c":
|
||||
GitCommitPromptOpened()
|
||||
-- status-line commit prompt
|
||||
if code = "u":
|
||||
GitPullStarted()
|
||||
-- ff-only, runs off the UI process, reports back as a
|
||||
-- status toast
|
||||
if code = "s":
|
||||
GitPushStarted()
|
||||
-- runs off the UI process, reports back as a status toast
|
||||
}
|
||||
|
||||
rule GitPanelNonRepo {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: TuiState.view = git
|
||||
requires: not is_git_repository(active_project())
|
||||
requires: code = "c" or code = "u" or code = "s"
|
||||
ensures: StatusMessageShown(message_key: "tui.not_a_git_repository")
|
||||
-- a project folder that is not a git repository shows a
|
||||
-- message and refuses the actions
|
||||
}
|
||||
|
||||
rule GitCommitSubmit {
|
||||
when: GitCommitPromptSubmitted(message)
|
||||
ensures:
|
||||
if message = "":
|
||||
GitCommitRejectedEmptyMessage()
|
||||
-- empty messages are rejected
|
||||
else:
|
||||
GitCommitExecuted(message)
|
||||
-- git add -A + commit
|
||||
}
|
||||
|
||||
-- ─── Report panels ──────────────────────────────────────────
|
||||
|
||||
rule ReportPanels {
|
||||
when: ShellTaskCompleted(route)
|
||||
requires: route = "metadata_diff" or route = "site_validation"
|
||||
-- Completed metadata diff and site validation tasks open a
|
||||
-- scrollable report panel showing the differences. Enter applies
|
||||
-- the whole report — metadata diff repairs all items from the
|
||||
-- filesystem (file → db) and imports orphan files; site validation
|
||||
-- applies the full report incrementally (render missing, remove
|
||||
-- extra, re-render updated), matching the GUI defaults. Esc closes
|
||||
-- the report without applying. Reports completed before this
|
||||
-- session started never pop up.
|
||||
ensures: ReportShownThenAppliedOrCancelled()
|
||||
requires: task_completed_in_current_session(route)
|
||||
-- reports completed before this session started never pop up
|
||||
ensures: ReportPanelOpened(route)
|
||||
-- scrollable report panel showing the differences
|
||||
}
|
||||
|
||||
rule ReportPanelApply {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = "enter"
|
||||
requires: report_panel_open()
|
||||
ensures: ReportApplied(open_report())
|
||||
-- enter applies the whole report: metadata diff repairs all
|
||||
-- items from the filesystem (file -> db) and imports orphan
|
||||
-- files; site validation applies the full report incrementally
|
||||
-- (render missing, remove extra, re-render updated) — matching
|
||||
-- the GUI defaults
|
||||
ensures: ReportPanelClosed()
|
||||
}
|
||||
|
||||
rule ReportPanelDismiss {
|
||||
when: TuiKeyPressed(code, _)
|
||||
requires: code = "esc"
|
||||
requires: report_panel_open()
|
||||
ensures: ReportPanelClosed()
|
||||
-- esc closes the report without applying
|
||||
}
|
||||
|
||||
-- ─── Multi-client sync ──────────────────────────────────────
|
||||
|
||||
rule MultiClientSync {
|
||||
when: TuiEventReceived(entity, entity_id, action)
|
||||
-- Domain events refresh the sidebar; a post deleted elsewhere closes
|
||||
-- the open editor. Keeps TUI sessions synchronized with GUI clients
|
||||
-- and pipeline ingestion.
|
||||
ensures: TuiState.updated()
|
||||
-- Keeps TUI sessions synchronized with GUI clients and pipeline
|
||||
-- ingestion.
|
||||
ensures: SidebarReloaded()
|
||||
ensures:
|
||||
if entity = "post" and action = "deleted" and edited_post_id() = entity_id:
|
||||
TuiState.editing_post = false
|
||||
-- a post deleted elsewhere closes the open editor
|
||||
}
|
||||
|
||||
rule ServerSideLocale {
|
||||
when: TuiSettingsChanged(key)
|
||||
requires: key = "ui.language"
|
||||
-- The TUI re-reads the server-side UI language and re-renders; all
|
||||
-- clients always share one language.
|
||||
ensures: TuiRelocalized()
|
||||
-- the TUI re-reads the server-side UI language and re-renders;
|
||||
-- all clients always share one language
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user