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: AISuggestionRequested(entity_type, entity_id)
|
||||||
provides: PostSaved(post_id)
|
provides: PostSaved(post_id)
|
||||||
provides: PostAutoTranslateCompleted(post_id, language)
|
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 {
|
provides: AiFieldSuggestionsResponded(entity_type, entity_id, suggestions)
|
||||||
-- All AI operations route through the active endpoint for the current mode.
|
-- The active endpoint's response, parsed into per-field suggestions
|
||||||
-- 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 ────────────────────────────
|
-- ─── 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.
|
rule AiOperationRefusedWithoutEndpoint {
|
||||||
-- See modals.allium AISuggestionsModal value type.
|
|
||||||
|
|
||||||
rule AISuggestionFlow {
|
|
||||||
when: AISuggestionRequested(entity_type, entity_id)
|
when: AISuggestionRequested(entity_type, entity_id)
|
||||||
-- 1. Check AIOperationGating (abort if offline and no local model)
|
requires: not active_endpoint_configured
|
||||||
-- 2. Send request to appropriate model:
|
ensures: ToastShown(message_key: "ai.unavailable_configure_endpoint")
|
||||||
-- Posts: title model, input = title + excerpt + first 2000 chars
|
-- "AI unavailable — configure {online|airplane} endpoint in Settings"
|
||||||
-- Media: image model, input = 448x448 AI thumbnail JPEG
|
ensures: AiOperationAborted(entity_type, entity_id)
|
||||||
-- 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 ────────────────────────────
|
-- ─── 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 {
|
rule AutoTranslationChain {
|
||||||
when: PostSaved(post_id)
|
when: PostSaved(post_id)
|
||||||
-- Gate: AIOperationGating + post.doNotTranslate must be false
|
-- Triggered only by explicit manual save or publish, never by
|
||||||
-- Triggered only by explicit manual save or publish, never by auto-save,
|
-- auto-save, unmount/tab-switch persistence, post creation, import,
|
||||||
-- unmount/tab-switch persistence, post creation, import, or scripting.
|
-- or scripting.
|
||||||
-- For each configured blogLanguage missing a translation for this post:
|
let saved_post = post/Post{id: post_id}
|
||||||
-- 1. Enqueue one background task for that language
|
requires: active_endpoint_configured
|
||||||
-- 2. Translate metadata (title, excerpt) via title model
|
requires: not saved_post.do_not_translate
|
||||||
-- 3. Translate content (full markdown) via title model
|
let missing = saved_post.project.blog_languages - saved_post.available_languages
|
||||||
-- 4. Create/update translation record in DB
|
for language in missing:
|
||||||
-- A later manual save with no missing languages enqueues no task.
|
ensures: PostTranslationTaskEnqueued(post_id, language)
|
||||||
-- Work is sequential within a language and parallel across languages.
|
-- One background task per missing language. Each task:
|
||||||
-- Progress visible in Tasks panel
|
-- 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 {
|
rule MediaMetadataTranslationCascade {
|
||||||
when: PostAutoTranslateCompleted(post_id, language)
|
when: PostAutoTranslateCompleted(post_id, language)
|
||||||
-- After a post translation completes for a given language:
|
let saved_post = post/Post{id: post_id}
|
||||||
-- For each media item linked to this post:
|
for link in saved_post.linked_media:
|
||||||
-- If media has source language set
|
let item = media/Media{id: link.media_id}
|
||||||
-- and no translation exists for {language}:
|
if item.language != null
|
||||||
-- Enqueue background task: translate media metadata
|
and not exists media/MediaTranslation{media: item, language: language}:
|
||||||
-- (title, alt, caption) via title model
|
ensures: MediaMetadataTranslationTaskEnqueued(link.media_id, language)
|
||||||
-- Creates translated sidecar file: {path}.{lang}.meta
|
-- 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.
|
rule PostDragDropImageChain {
|
||||||
-- See editor_post.allium PostDragDropImage for trigger rule.
|
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):
|
rule DragDropAiAutoApply {
|
||||||
-- 1. importMedia(file) -> new media record + file copy + base sidecar
|
when: DragDropImportCompleted(target, imported)
|
||||||
-- 2. generateThumbnails(media) -> async start (small/medium/large/ai)
|
requires: active_endpoint_configured
|
||||||
-- 3. linkMediaToPost(media, post) -> update sidecar linkedPostIds
|
-- Background, non-blocking: image model on the 448x448 AI thumbnail.
|
||||||
-- 4. insertMarkdownImage(cursor) -> insert  at cursor
|
-- 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):
|
rule DragDropTranslationCascade {
|
||||||
-- 5. If AI available: aiImageAnalysis(media)
|
when: DragDropImportCompleted(target, imported)
|
||||||
-- Uses image model on 448x448 AI thumbnail
|
requires: active_endpoint_configured
|
||||||
-- Results auto-applied to media metadata (NO modal for drag-drop,
|
requires: not target.do_not_translate
|
||||||
-- unlike manual Quick Action which shows AISuggestionsModal)
|
for language in target.project.blog_languages:
|
||||||
-- Triggers sidecar rewrite
|
ensures: MediaMetadataTranslationTaskEnqueued(imported.id, language)
|
||||||
-- 6. If auto-translate enabled (post.doNotTranslate=false):
|
-- creates translated sidecar files
|
||||||
-- For each blogLanguage: translateMediaMetadata(media, lang)
|
}
|
||||||
-- 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
|
value ConfirmationAssignment {
|
||||||
-- Simple yes/no system dialog, no custom UI
|
action: String
|
||||||
-- Used by: PostDelete, PostDiscard, TemplateDelete (when references exist)
|
pattern: ConfirmationPattern
|
||||||
|
}
|
||||||
|
|
||||||
-- Pattern 2: ConfirmDeleteModal (custom modal with reference info)
|
config {
|
||||||
-- Shows entity name, reference counts, linked entity list
|
confirmation_assignments: Set<ConfirmationAssignment> = {
|
||||||
-- Two buttons: Cancel, Delete (destructive red style)
|
ConfirmationAssignment(action: "post_delete", pattern: system_confirm),
|
||||||
-- Used by: MediaDelete (shows linked posts), TagDelete (shows post count)
|
ConfirmationAssignment(action: "post_discard", pattern: system_confirm),
|
||||||
|
ConfirmationAssignment(action: "template_delete_with_references", pattern: system_confirm),
|
||||||
-- Pattern 3: ConfirmDialog (custom modal for non-delete confirmations)
|
ConfirmationAssignment(action: "media_delete", pattern: confirm_delete_modal),
|
||||||
-- Shows description of action and consequences
|
-- shows linked posts
|
||||||
-- Two buttons: Cancel, Confirm
|
ConfirmationAssignment(action: "tag_delete", pattern: confirm_delete_modal),
|
||||||
-- Used by: TagMerge ("Merge N tags into {target}? Cannot be undone.")
|
-- shows post count
|
||||||
|
ConfirmationAssignment(action: "tag_merge", pattern: confirm_dialog),
|
||||||
-- Pattern 4: No confirmation (immediate execution)
|
-- "Merge N tags into {target}? Cannot be undone."
|
||||||
-- Action executes on click, no dialog
|
ConfirmationAssignment(action: "rebuild_operations", pattern: none),
|
||||||
-- Used by: all Rebuild operations, ScriptDelete, MenuItemDelete,
|
ConfirmationAssignment(action: "script_delete", pattern: none),
|
||||||
-- MetadataDiff per-field sync, ImportExecute, MediaTranslationDelete,
|
ConfirmationAssignment(action: "menu_item_delete", pattern: none),
|
||||||
-- MediaUnlink
|
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.
|
-- the main content area when a media tab is active.
|
||||||
|
|
||||||
use "./media.allium" as media
|
use "./media.allium" as media
|
||||||
|
use "./post.allium" as post
|
||||||
use "./i18n.allium" as i18n
|
use "./i18n.allium" as i18n
|
||||||
|
|
||||||
-- ─── Media editor ─────────────────────────────────────────────
|
-- ─── Media editor ─────────────────────────────────────────────
|
||||||
@@ -56,6 +57,9 @@ surface PostPickerOverlaySurface {
|
|||||||
result.post_id
|
result.post_id
|
||||||
result.title
|
result.title
|
||||||
overlay.overflow_count when overlay.overflow_count != null
|
overlay.overflow_count when overlay.overflow_count != null
|
||||||
|
|
||||||
|
provides:
|
||||||
|
PostPickerResultClicked(media_id, post_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
value PostPickerResult {
|
value PostPickerResult {
|
||||||
@@ -103,6 +107,8 @@ surface MediaEditorSurface {
|
|||||||
MediaTranslationRefreshClicked(editor.media_id, language)
|
MediaTranslationRefreshClicked(editor.media_id, language)
|
||||||
MediaTranslationDeleteClicked(editor.media_id, language)
|
MediaTranslationDeleteClicked(editor.media_id, language)
|
||||||
MediaUnlinkPostRequested(editor.media_id, post_id)
|
MediaUnlinkPostRequested(editor.media_id, post_id)
|
||||||
|
MediaDeleteConfirmed(editor.media_id)
|
||||||
|
TranslationEditModalSaved(editor.media_id, language, title, alt, caption)
|
||||||
|
|
||||||
@guarantee HeaderLayout
|
@guarantee HeaderLayout
|
||||||
-- Header bar with media display name.
|
-- Header bar with media display name.
|
||||||
@@ -162,84 +168,153 @@ surface MediaEditorSurface {
|
|||||||
|
|
||||||
-- ─── Media editor actions ─────────────────────────────────────
|
-- ─── 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 {
|
rule MediaAIImageAnalysis {
|
||||||
when: MediaAIImageAnalysisRequested(media_id)
|
when: MediaAIImageAnalysisRequested(media_id)
|
||||||
-- Gate: airplane mode check (see action_patterns.allium AIOperationGating)
|
-- Only available for image/* MIME types (editor.is_image guard
|
||||||
-- Only available for image/* MIME types (button hidden for non-images)
|
-- on the provides entry; button hidden for non-images).
|
||||||
-- Uses image analysis model (vision-capable, not title model)
|
ensures: AISuggestionRequested(entity_type: "media", entity_id: media_id)
|
||||||
-- Input: AI-optimized JPEG thumbnail (448x448, generated on import)
|
-- Shared flow: action_patterns.allium MediaAISuggestionFlow —
|
||||||
-- Response: suggested title, alt text, caption
|
-- airplane gating, image model over the 448x448 AI thumbnail,
|
||||||
-- Opens AISuggestionsModal with 3 fields (title, alt, caption)
|
-- AISuggestionsModal with title/alt/caption, accepted fields
|
||||||
-- On confirm: applies checked fields, triggers explicit save
|
-- trigger an explicit save.
|
||||||
}
|
}
|
||||||
|
|
||||||
rule MediaDetectLanguage {
|
rule MediaDetectLanguage {
|
||||||
when: MediaDetectLanguageRequested(media_id)
|
when: MediaDetectLanguageRequested(media_id)
|
||||||
-- Gate: airplane mode check
|
let item = media/Media{id: media_id}
|
||||||
-- Input: concatenation of title + alt + caption text
|
requires: active_endpoint_configured
|
||||||
-- Response: detected language code
|
-- airplane-mode gating: ai.allium AirplaneModeGating
|
||||||
-- Immediately persists to media record (no modal, no confirmation)
|
let detected = ai_detect_language(concat(item.title, item.alt, item.caption))
|
||||||
-- Triggers sidecar rewrite
|
-- 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 {
|
rule MediaTranslateMetadata {
|
||||||
when: MediaTranslateMetadataRequested(media_id, target_language)
|
when: MediaTranslateMetadataRequested(media_id, target_language)
|
||||||
-- Gate: airplane mode check
|
let item = media/Media{id: media_id}
|
||||||
-- Opens language picker modal (same pattern as post translate)
|
requires: active_endpoint_configured
|
||||||
-- Two-step process:
|
-- target_language comes from the language picker modal (same
|
||||||
-- 1. If source language not set: detect it first (auto-persist)
|
-- pattern as post translate).
|
||||||
-- 2. Translate title, alt, caption to target language via title model
|
let source_language = item.language
|
||||||
-- Creates/updates media translation record
|
?? ai_detect_language(concat(item.title, item.alt, item.caption))
|
||||||
-- Writes translated sidecar file: {path}.{lang}.meta
|
-- 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 {
|
rule MediaReplaceFile {
|
||||||
when: MediaReplaceFileRequested(media_id)
|
when: MediaReplaceFileRequested(media_id)
|
||||||
-- Opens native file dialog (no MIME type filter)
|
let item = media/Media{id: media_id}
|
||||||
-- Copies selected file over existing media file path
|
let source = native_file_dialog_selection()
|
||||||
-- If image: regenerates thumbnails synchronously (awaited)
|
-- native file dialog, no MIME type filter; cancel aborts
|
||||||
-- Preview area updates with cache-busting timestamp query param
|
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 {
|
rule MediaDeleteAction {
|
||||||
when: MediaDeleteRequested(media_id)
|
when: MediaDeleteRequested(media_id)
|
||||||
-- Opens ConfirmDeleteModal (custom modal, not native dialog)
|
let item = media/Media{id: media_id}
|
||||||
-- Shows: media display name, linked posts count and list
|
ensures: ConfirmDeleteModalOpened(media_id, item.linked_posts)
|
||||||
-- Two buttons: Cancel, Delete (destructive red style)
|
-- custom modal (not a native dialog): media display name,
|
||||||
-- On confirm: deletes file, sidecar, thumbnails, all translations,
|
-- linked post count and list; Cancel + Delete (destructive
|
||||||
-- post-media links, FTS index entry
|
-- red style) — action_patterns.allium
|
||||||
-- Closes media tab, sidebar removes item
|
-- confirmation_assignments: media_delete
|
||||||
-- See engine_side_effects.allium DeleteMediaSideEffects
|
}
|
||||||
|
|
||||||
|
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 {
|
rule MediaLinkToPost {
|
||||||
when: MediaLinkToPostRequested(media_id)
|
when: MediaLinkToPostRequested(media_id)
|
||||||
-- Opens inline post picker overlay (not a modal, positioned near button)
|
ensures: PostPickerOverlayOpened(media_id)
|
||||||
-- Search input filtering unlinked posts by title
|
-- inline overlay near the button (not a modal): search input
|
||||||
-- Up to config.media_post_picker_max_results results shown
|
-- filters unlinked posts by title, at most
|
||||||
-- "and N more" text if results exceed limit
|
-- config.media_post_picker_max_results results, "and N more"
|
||||||
-- Click links media to selected post (updates sidecar linkedPostIds)
|
-- when the total exceeds the limit
|
||||||
-- Linked posts list refreshes immediately
|
}
|
||||||
|
|
||||||
|
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 {
|
rule MediaTranslationEdit {
|
||||||
when: MediaTranslationEditClicked(media_id, language)
|
when: MediaTranslationEditClicked(media_id, language)
|
||||||
-- Opens the "Edit Translation" modal pre-filled with the translation's
|
ensures: TranslationEditModalOpened(media_id, language)
|
||||||
-- title, alt, and caption for that language
|
-- "Edit Translation" modal pre-filled with the translation's
|
||||||
-- Save persists to DB + translated sidecar {path}.{lang}.meta; Cancel discards
|
-- 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 {
|
rule MediaTranslationRefresh {
|
||||||
when: MediaTranslationRefreshClicked(media_id, language)
|
when: MediaTranslationRefreshClicked(media_id, language)
|
||||||
-- Gate: airplane mode check
|
let item = media/Media{id: media_id}
|
||||||
-- Re-translates from source language to target via title model
|
requires: active_endpoint_configured
|
||||||
-- Overwrites existing translation fields
|
let translated = ai_translate_media_metadata(item, item.language, language)
|
||||||
-- Rewrites translated sidecar file
|
-- 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 {
|
rule MediaTranslationDelete {
|
||||||
when: MediaTranslationDeleteClicked(media_id, language)
|
when: MediaTranslationDeleteClicked(media_id, language)
|
||||||
-- Deletes translation record from DB
|
let item = media/Media{id: media_id}
|
||||||
-- Deletes translated sidecar file: {path}.{lang}.meta
|
let translation = media/MediaTranslation{media: item, language: language}
|
||||||
-- No confirmation dialog
|
-- 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)
|
PostGalleryRequested(editor.post_id)
|
||||||
ImageDroppedOnEditor(editor.post_id, file_path)
|
ImageDroppedOnEditor(editor.post_id, file_path)
|
||||||
PostLanguageDetectRequested(editor.post_id)
|
PostLanguageDetectRequested(editor.post_id)
|
||||||
|
PostDiscardConfirmed(editor.post_id)
|
||||||
|
PostDeleteConfirmed(editor.post_id)
|
||||||
|
|
||||||
@guarantee HeaderLayout
|
@guarantee HeaderLayout
|
||||||
-- Header bar with two areas.
|
-- Header bar with two areas.
|
||||||
@@ -207,119 +209,136 @@ invariant PostEditorModePersistence {
|
|||||||
|
|
||||||
rule PostAIAnalysis {
|
rule PostAIAnalysis {
|
||||||
when: PostAIAnalysisRequested(post_id)
|
when: PostAIAnalysisRequested(post_id)
|
||||||
-- Gate: airplane mode check (see action_patterns.allium AIOperationGating)
|
ensures: AISuggestionRequested(entity_type: "post", entity_id: post_id)
|
||||||
-- Uses title model (not default chat model)
|
-- Shared flow: action_patterns.allium PostAISuggestionFlow —
|
||||||
-- Input: post title + excerpt + content (first config.post_content_sample_length chars)
|
-- airplane gating, title model (not the default chat model)
|
||||||
-- Response: suggested title, excerpt, slug
|
-- over title + excerpt + content sample, AISuggestionsModal
|
||||||
-- Opens AISuggestionsModal with 3 fields:
|
-- with title/excerpt/slug (slug locked once published),
|
||||||
-- Each field: current value, suggested value, accept checkbox
|
-- accepted fields re-arm the auto-save timer.
|
||||||
-- Slug field locked (no accept checkbox) if post was ever published
|
|
||||||
-- On confirm: applies only checked fields, triggers auto-save
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rule PostTranslateAction {
|
rule PostTranslateAction {
|
||||||
when: PostTranslateRequested(post_id, target_language)
|
when: PostTranslateRequested(post_id, target_language)
|
||||||
-- Gate: airplane mode check
|
let target = post/Post{id: post_id}
|
||||||
-- Opens language picker modal:
|
requires: active_endpoint_configured
|
||||||
-- Available target languages from project blogLanguages
|
-- airplane-mode gating: ai.allium AirplaneModeGating
|
||||||
-- Existing translations shown with status badge (draft/published)
|
requires: target_language in target.project.blog_languages
|
||||||
-- Two sequential AI calls via title model:
|
-- chosen in the language picker modal; existing translations
|
||||||
-- 1. Translate metadata (title, excerpt) to target language
|
-- are listed with their status badge (draft/published)
|
||||||
-- 2. Translate content (full markdown body) to target language
|
let translated_meta = ai_translate_post_metadata(target, target_language)
|
||||||
-- Creates/updates translation record in DB
|
-- AI call 1: title + excerpt via the title model
|
||||||
-- If source post is published: transitions source to draft
|
let translated_content = ai_translate_post_content(target, target_language)
|
||||||
-- (copies file content back to DB so it can be edited)
|
-- 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 {
|
-- Auto-translation on manual save is specified once in
|
||||||
when: PostSaved(post_id)
|
-- action_patterns.allium AutoTranslationChain (triggered by PostSaved),
|
||||||
-- Only an explicit manual save (or publish) triggers this. Auto-saves and
|
-- including the linked-media cascade. It is not duplicated here.
|
||||||
-- 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
|
|
||||||
}
|
|
||||||
|
|
||||||
rule PostPublishAction {
|
rule PostPublishAction {
|
||||||
when: PostPublishRequested(post_id)
|
when: PostPublishRequested(post_id)
|
||||||
-- Implicit save first (awaited) if post is dirty
|
let target = post/Post{id: post_id}
|
||||||
-- Then calls engine publish (see engine_side_effects.allium PublishPostSideEffects)
|
ensures:
|
||||||
-- Also publishes all translations whose source language is published
|
if editor_is_dirty(post_id):
|
||||||
-- UI updates: status badge -> published, sidebar section move
|
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 {
|
rule PostDiscardChanges {
|
||||||
when: PostDiscardRequested(post_id)
|
when: PostDiscardRequested(post_id)
|
||||||
-- Only available for published posts with pending draft changes
|
let target = post/Post{id: post_id}
|
||||||
-- System confirm dialog: "Discard changes to this post?"
|
requires: target.published_at != null
|
||||||
-- On confirm: reads published version from .md file,
|
requires: target.status = draft
|
||||||
-- restores DB to published state (content=null, status=published)
|
-- only published posts with pending draft changes
|
||||||
-- Editor reloads with restored content
|
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 {
|
rule PostDeleteAction {
|
||||||
when: PostDeleteRequested(post_id)
|
when: PostDeleteRequested(post_id)
|
||||||
-- System confirm dialog: "Delete this post?"
|
ensures: DeleteConfirmShown(post_id)
|
||||||
-- If published: also deletes .md file and all translation files
|
-- system confirm dialog: "Delete this post?"
|
||||||
-- If never published: only deletes DB record
|
-- (action_patterns.allium confirmation_assignments: post_delete)
|
||||||
-- Removes from DB, closes tab, sidebar removes item
|
}
|
||||||
-- See engine_side_effects.allium DeletePostSideEffects
|
|
||||||
|
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 {
|
rule PostInsertLink {
|
||||||
when: PostInsertLinkRequested(post_id)
|
when: PostInsertLinkRequested(post_id)
|
||||||
-- Keyboard shortcut: Ctrl/Cmd+K
|
-- Keyboard shortcut: Ctrl/Cmd+K (markdown mode only, per the
|
||||||
-- Opens InsertPostLinkModal with two tabs: Internal, External
|
-- provides guard)
|
||||||
-- Internal tab:
|
ensures: InsertPostLinkModalOpened(post_id)
|
||||||
-- Search input (debounced, queries post titles)
|
-- two tabs: Internal (debounced title search, similarity
|
||||||
-- Results list: title + status badge (draft/published)
|
-- ranking, Create Post row) and External (URL + display text);
|
||||||
-- If semantic similarity enabled: results ranked by similarity
|
-- picked entries insert [title](/YYYY/MM/DD/slug) or
|
||||||
-- Click inserts markdown link: [title](/YYYY/MM/DD/slug)
|
-- [text](url) at the cursor — modals.allium InsertPostLinkModal
|
||||||
-- "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 {
|
rule PostInsertMedia {
|
||||||
when: PostInsertMediaRequested(post_id)
|
when: PostInsertMediaRequested(post_id)
|
||||||
-- Opens InsertMediaModal (media search variant)
|
ensures: InsertMediaModalOpened(post_id)
|
||||||
-- Search input, grid of media items with bds-thumb:// thumbnails
|
-- media search grid with bds-thumb:// thumbnails; picked items
|
||||||
-- Click inserts markdown:
|
-- insert  for images or
|
||||||
-- Images: 
|
-- [originalName](/media/YYYY/MM/file) otherwise —
|
||||||
-- Non-images: [originalName](/media/YYYY/MM/file)
|
-- modals.allium InsertMediaModal
|
||||||
}
|
}
|
||||||
|
|
||||||
rule PostGalleryAction {
|
rule PostGalleryAction {
|
||||||
when: PostGalleryRequested(post_id)
|
when: PostGalleryRequested(post_id)
|
||||||
-- Opens gallery overlay showing all media linked to this post
|
ensures: GalleryOverlayOpened(post_id)
|
||||||
-- Image grid with bds-thumb:// thumbnails
|
-- grid of all media linked to this post; clicking an image
|
||||||
-- Click on image opens lightbox (full-size bds-media:// preview)
|
-- opens the lightbox (full-size preview, arrow navigation,
|
||||||
-- Lightbox: left/right arrow navigation, close button, ESC to close
|
-- ESC to close) — modals.allium GalleryOverlay / LightboxView
|
||||||
}
|
}
|
||||||
|
|
||||||
rule PostDragDropImage {
|
rule PostDragDropImage {
|
||||||
when: ImageDroppedOnEditor(post_id, file_path)
|
when: ImageDroppedOnEditor(post_id, file_path)
|
||||||
-- Chain of operations (see action_patterns.allium DragDropImageChain):
|
ensures: ImageFileDroppedOnPostEditor(post_id, file_path)
|
||||||
-- 1. Import media file -> media record + file copy + sidecar
|
-- full chain: action_patterns.allium PostDragDropImageChain
|
||||||
-- 2. Generate thumbnails (async: small/medium/large/ai)
|
-- (synchronous import + thumbnails + link + cursor insert,
|
||||||
-- 3. Link media to post (update sidecar linkedPostIds)
|
-- background AI auto-apply and translation cascade)
|
||||||
-- 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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rule PostLanguageDetect {
|
rule PostLanguageDetect {
|
||||||
when: PostLanguageDetectRequested(post_id)
|
when: PostLanguageDetectRequested(post_id)
|
||||||
-- Gate: airplane mode check
|
let target = post/Post{id: post_id}
|
||||||
-- Sends content sample to title model
|
requires: active_endpoint_configured
|
||||||
-- Auto-sets post language field (no modal)
|
let detected = ai_detect_language(post_suggestion_input(target))
|
||||||
-- Triggers auto-save
|
-- title model over the content sample; no modal
|
||||||
|
ensures: target.language = detected
|
||||||
|
ensures: PostAutoSaveTimerReset(post_id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,40 +70,66 @@ surface ScriptEditorSurface {
|
|||||||
|
|
||||||
rule ScriptSave {
|
rule ScriptSave {
|
||||||
when: ScriptSaveRequested(script_id)
|
when: ScriptSaveRequested(script_id)
|
||||||
-- Lua syntax check first using the configured runtime semantics
|
let target = script/Script{id: script_id}
|
||||||
-- If syntax error: blocks save, shows error inline in editor gutter
|
let syntax = ValidateScript(editor_content(script_id))
|
||||||
-- If valid: bumps version, saves to DB + rewrites the published script file
|
-- editor_content: the current code editor buffer
|
||||||
-- Entrypoint list re-discovered from Lua source after save
|
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 {
|
rule ScriptPublish {
|
||||||
when: ScriptPublishRequested(script_id)
|
when: ScriptPublishRequested(script_id)
|
||||||
-- Only offered while the script is a draft (can_publish gate on the button)
|
let target = script/Script{id: script_id}
|
||||||
-- Validates Lua syntax first (publish gate); invalid source blocks publish
|
requires: target.status = draft
|
||||||
-- Saves current draft fields, then publishes (status -> published)
|
-- can_publish gate on the button
|
||||||
-- Writes the published script file to disk
|
let syntax = ValidateScript(editor_content(script_id))
|
||||||
-- See script.allium PublishScript
|
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 {
|
rule ScriptCheckSyntax {
|
||||||
when: ScriptCheckSyntaxRequested(script_id)
|
when: ScriptCheckSyntaxRequested(script_id)
|
||||||
-- Validates Lua syntax without saving
|
-- Validation only, never saves
|
||||||
-- Shows errors inline in editor gutter
|
let syntax = ValidateScript(editor_content(script_id))
|
||||||
-- Success shown as toast or status indicator
|
ensures:
|
||||||
|
if syntax = valid:
|
||||||
|
ScriptSyntaxOkShown(script_id)
|
||||||
|
-- toast / status indicator
|
||||||
|
else:
|
||||||
|
ScriptSyntaxErrorsShown(script_id, syntax)
|
||||||
}
|
}
|
||||||
|
|
||||||
rule ScriptRun {
|
rule ScriptRun {
|
||||||
when: ScriptRunRequested(script_id)
|
when: ScriptRunRequested(script_id)
|
||||||
-- Executes the script in the configured Lua runtime
|
let target = script/Script{id: script_id}
|
||||||
-- Calls the configured Lua entrypoint function
|
ensures: ExecuteScriptRequested(target, editor_entrypoint(script_id),
|
||||||
-- stdout/stderr directed to Output panel tab
|
empty_args(), output_panel_sink())
|
||||||
-- Output panel auto-opens if not already visible
|
-- Runs the current buffer's entrypoint in the sandboxed Lua
|
||||||
-- Errors shown in Output panel with line numbers
|
-- 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 {
|
rule ScriptDelete {
|
||||||
when: ScriptDeleteRequested(script_id)
|
when: ScriptDeleteRequested(script_id)
|
||||||
-- No confirmation dialog
|
let target = script/Script{id: script_id}
|
||||||
-- Deletes DB record + published script file on disk
|
-- No confirmation dialog (action_patterns.allium
|
||||||
-- Closes script tab, sidebar removes item
|
-- 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
|
when editor.can_publish
|
||||||
TemplateValidateRequested(editor.template_id)
|
TemplateValidateRequested(editor.template_id)
|
||||||
TemplateDeleteRequested(editor.template_id)
|
TemplateDeleteRequested(editor.template_id)
|
||||||
|
TemplateForceDeleteConfirmed(editor.template_id)
|
||||||
|
|
||||||
@guarantee HeaderLayout
|
@guarantee HeaderLayout
|
||||||
-- Header bar with template title tab.
|
-- Header bar with template title tab.
|
||||||
@@ -65,36 +66,75 @@ surface TemplateEditorSurface {
|
|||||||
|
|
||||||
rule TemplateSave {
|
rule TemplateSave {
|
||||||
when: TemplateSaveRequested(template_id)
|
when: TemplateSaveRequested(template_id)
|
||||||
-- Liquid validation first (parse check for syntax errors)
|
let target = template/Template{id: template_id}
|
||||||
-- If invalid: blocks save, shows error inline in editor
|
let validation = ValidateLiquid(editor_content(template_id))
|
||||||
-- If valid: bumps version, saves to DB + rewrites .liquid file
|
-- editor_content: the current code editor buffer
|
||||||
-- See engine_side_effects.allium UpdateTemplateSideEffects
|
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 {
|
rule TemplatePublish {
|
||||||
when: TemplatePublishRequested(template_id)
|
when: TemplatePublishRequested(template_id)
|
||||||
-- Only offered while the template is a draft (can_publish gate on the button)
|
let target = template/Template{id: template_id}
|
||||||
-- Validates Liquid first (publish gate); invalid source blocks publish
|
requires: target.status = draft
|
||||||
-- Saves current draft fields, then publishes (status -> published)
|
-- can_publish gate on the button
|
||||||
-- Writes the published .liquid file to disk
|
let validation = ValidateLiquid(editor_content(template_id))
|
||||||
-- See template.allium PublishTemplate
|
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 {
|
rule TemplateValidate {
|
||||||
when: TemplateValidateRequested(template_id)
|
when: TemplateValidateRequested(template_id)
|
||||||
-- Validates Liquid syntax without saving
|
-- Validation only, never saves
|
||||||
-- Shows errors inline in editor
|
let validation = ValidateLiquid(editor_content(template_id))
|
||||||
-- Success shown as toast or status indicator
|
ensures:
|
||||||
|
if validation = valid:
|
||||||
|
TemplateValidationOkShown(template_id)
|
||||||
|
-- toast / status indicator
|
||||||
|
else:
|
||||||
|
TemplateValidationErrorsShown(template_id, validation)
|
||||||
}
|
}
|
||||||
|
|
||||||
rule TemplateDelete {
|
rule TemplateDelete {
|
||||||
when: TemplateDeleteRequested(template_id)
|
when: TemplateDeleteRequested(template_id)
|
||||||
-- Checks for references: posts using this template, tags with postTemplateSlug
|
let target = template/Template{id: template_id}
|
||||||
-- If references exist: system confirm dialog
|
let references = target.referencing_posts.count + target.referencing_tags.count
|
||||||
-- "This template is used by N posts and M tags. Force delete?"
|
ensures:
|
||||||
-- Force delete: nulls templateSlug on referencing posts,
|
if references > 0:
|
||||||
-- nulls postTemplateSlug on referencing tags
|
TemplateForceDeleteConfirmShown(template_id)
|
||||||
-- If no references: deletes without confirmation
|
-- system confirm dialog: "This template is used by N posts
|
||||||
-- Deletes DB record + .liquid file on disk
|
-- and M tags. Force delete?" (action_patterns.allium
|
||||||
-- Closes template tab, sidebar removes item
|
-- 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"
|
default_language: String = "en"
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant SplitLocalization {
|
-- ─── Split localization ─────────────────────────────────────
|
||||||
-- Two independent locale scopes:
|
--
|
||||||
-- 1. UI locale: follows OS system locale
|
-- Two independent locale scopes exist per running app. They are
|
||||||
-- 2. Content/render locale: follows project settings (mainLanguage)
|
-- resolved by separate rules from separate inputs and may differ.
|
||||||
-- These are resolved independently 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 {
|
surface LocaleSurface {
|
||||||
-- Input language codes are normalized:
|
facing _: LocaleConsumer
|
||||||
-- Take base language code (split on '-'): "en-US" -> "en"
|
|
||||||
-- Fall back to "en" if unrecognized
|
provides:
|
||||||
|
OsLocaleDetected(os_locale)
|
||||||
|
ProjectMainLanguageResolved(main_language)
|
||||||
|
MenuTranslationRequested(item_key)
|
||||||
|
RenderTranslationRequested(label_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant MenuTranslations {
|
-- ─── Language normalization ─────────────────────────────────
|
||||||
-- Menu item labels are separately translatable
|
--
|
||||||
-- translateMenu() uses UI locale (system/OS locale), NOT render locale
|
-- Both scopes normalize their input the same way: take the base
|
||||||
-- This follows the OS convention: menus match the system language
|
-- 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,
|
-- Template rendering i18n strings (date formats, archive labels,
|
||||||
-- "older posts", "newer posts", etc.) come from locale JSON files
|
-- "older posts", "newer posts", etc.) come from the locale JSON
|
||||||
-- translateRender() and getRenderTranslations() provide these
|
-- 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 {
|
invariant SnowballStemmerCoverage {
|
||||||
-- 24 languages supported for FTS5 search stemming
|
-- ISO 639-1 codes with a dedicated Snowball algorithm
|
||||||
-- ISO 639-1 mapped to Snowball stemmer names
|
-- (stemmer_for_language in bds-core fts.rs); nb/nn/no share the
|
||||||
-- All 5 UI languages are a subset of stemmer languages
|
-- 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 {
|
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 {
|
invariant UniquePostSlugPerProject {
|
||||||
-- posts.slug must be unique within each project.project_id
|
|
||||||
-- Enforced by: posts_project_slug_idx unique index
|
-- 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 {
|
invariant UniqueTranslationPerPostLanguage {
|
||||||
-- post_translations must have unique (translation_for, language)
|
|
||||||
-- Enforced by: post_translations_translation_language_idx
|
-- 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 {
|
invariant UniqueMediaTranslationPerMediaLanguage {
|
||||||
-- media_translations must have unique (translation_for, language)
|
|
||||||
-- Enforced by: media_translations_translation_language_idx
|
-- 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 {
|
invariant UniqueTagNamePerProject {
|
||||||
-- tags.name must be unique within each project.project_id
|
|
||||||
-- Enforced by: tags_project_name_idx unique index
|
-- 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 {
|
invariant UniqueScriptSlugPerProject {
|
||||||
-- scripts.slug must be unique within each project.project_id
|
|
||||||
-- Enforced by: scripts_project_slug_idx unique index
|
-- 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 {
|
invariant UniqueTemplateSlugPerProject {
|
||||||
-- templates.slug must be unique within each project.project_id
|
|
||||||
-- Enforced by: templates_project_slug_idx unique index
|
-- 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 {
|
invariant UniquePostMediaLink {
|
||||||
-- post_media must have unique (post_id, media_id) pair
|
|
||||||
-- Enforced by: post_media_post_media_idx unique index
|
-- 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 {
|
invariant UniqueGeneratedFileHash {
|
||||||
-- generated_file_hashes must have unique (project_id, relative_path)
|
|
||||||
-- Enforced by: generated_file_hashes_project_path_idx unique index
|
-- 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 {
|
invariant UniqueDismissedDuplicatePair {
|
||||||
-- dismissed_duplicate_pairs must have unique (project_id, post_id_a, post_id_b)
|
|
||||||
-- Enforced by: dismissed_pairs_idx unique index
|
-- 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
|
ensures: not exists template
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Exact Liquid subset required (distilled from bundled starter templates)
|
-- ─── Liquid subset (compatibility contract) ─────────────────
|
||||||
-- No features beyond this list are used.
|
--
|
||||||
|
-- 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 {
|
invariant LiquidTagSubset {
|
||||||
-- Only these 5 tags are used:
|
for t in Templates:
|
||||||
-- {% if %} / {% elsif %} / {% else %} / {% endif %}
|
t.status = published implies
|
||||||
-- {% for %} / {% endfor %}
|
for tag in liquid_tags(effective_template_content(t)):
|
||||||
-- {% assign %}
|
tag in config.liquid_allowed_tags
|
||||||
-- {% 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 {
|
invariant LiquidFilterSubset {
|
||||||
-- Standard filters (4):
|
let allowed = config.liquid_allowed_standard_filters
|
||||||
-- | escape
|
+ config.liquid_allowed_custom_filters
|
||||||
-- | url_encode
|
for t in Templates:
|
||||||
-- | default: fallback_value
|
t.status = published implies
|
||||||
-- | append: suffix_string
|
for f in liquid_filters(effective_template_content(t)):
|
||||||
--
|
f in allowed
|
||||||
-- 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant LiquidOperatorSubset {
|
invariant LiquidOperatorSubset {
|
||||||
-- Comparison: ==, >
|
for t in Templates:
|
||||||
-- Logical: or, and
|
t.status = published implies
|
||||||
-- Truthy/falsy: bare variable in {% if variable %}
|
for op in liquid_comparison_operators(effective_template_content(t)):
|
||||||
-- Special values: blank (nil/empty comparison)
|
op in config.liquid_allowed_comparison_operators
|
||||||
-- 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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant LiquidRenderContext {
|
-- The top-level variables available to templates at render time are
|
||||||
-- Template rendering context provides these top-level variables:
|
-- formalized as the RenderContext value records in template_context.allium.
|
||||||
-- 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
|
|
||||||
}
|
|
||||||
|
|||||||
530
specs/tui.allium
530
specs/tui.allium
@@ -8,11 +8,22 @@ entity TuiState {
|
|||||||
focus: sidebar | editor
|
focus: sidebar | editor
|
||||||
selected_index: Integer
|
selected_index: Integer
|
||||||
editing_post: Boolean
|
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:
|
-- The editor drives the same BDS.UI.PostEditor workflow as the GUI:
|
||||||
-- canonical-language edits update the post, other languages update
|
-- canonical-language edits update the post, other languages update
|
||||||
-- translations; publish routes through the same publishing pipeline.
|
-- 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 {
|
surface TuiSurface {
|
||||||
facing _: TuiRuntime
|
facing _: TuiRuntime
|
||||||
|
|
||||||
@@ -21,207 +32,468 @@ surface TuiSurface {
|
|||||||
TuiEventReceived(entity, entity_id, action)
|
TuiEventReceived(entity, entity_id, action)
|
||||||
TuiSettingsChanged(key)
|
TuiSettingsChanged(key)
|
||||||
ShellTaskCompleted(route)
|
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 {
|
rule SidebarNavigation {
|
||||||
when: TuiKeyPressed(code, _)
|
when: TuiKeyPressed(code, _)
|
||||||
requires: code = "up" or code = "down" or code = "j" or code = "k"
|
requires: code = "up" or code = "down" or code = "j" or code = "k"
|
||||||
-- Selection moves over the flattened sidebar items and skips
|
requires: TuiState.focus = sidebar
|
||||||
-- section headers; data comes from BDS.UI.Sidebar.view, identical
|
let items = flattened_sidebar_items()
|
||||||
-- to the GUI sidebar content.
|
-- BDS.UI.Sidebar.view data, identical to the GUI sidebar
|
||||||
ensures: TuiState.selected_index.updated()
|
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 {
|
rule OpenEntry {
|
||||||
when: TuiKeyPressed(code, _)
|
when: TuiKeyPressed(code, _)
|
||||||
requires: code = "enter"
|
requires: code = "enter"
|
||||||
-- Posts open in the editor by default (title + textarea seeded from
|
requires: TuiState.focus = sidebar
|
||||||
-- the persisted form; syntax highlighting and word wrap on; ctrl+e
|
let entry = selected_sidebar_entry(TuiState.selected_index)
|
||||||
-- switches to the rendered preview). New empty posts created with
|
ensures:
|
||||||
-- "n" open in the editor as well. Images open in the terminal image
|
if entry.kind = "post":
|
||||||
-- preview. Other entities report that terminal editing is not
|
TuiState.editing_post = true
|
||||||
-- available yet.
|
TuiState.focus = editor
|
||||||
ensures: TuiState.editing_post.updated()
|
-- 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 {
|
rule SaveAndPublish {
|
||||||
when: TuiKeyPressed(code, modifiers)
|
when: TuiKeyPressed(code, modifiers)
|
||||||
requires: code = "s" or code = "p"
|
requires: code = "s" or code = "p"
|
||||||
requires: modifiers = "ctrl"
|
requires: modifiers = "ctrl"
|
||||||
-- ctrl+s saves the draft, ctrl+p saves and publishes — both through
|
requires: TuiState.editing_post
|
||||||
-- BDS.UI.PostEditor.Persistence, so files, embeddings, search, and
|
ensures:
|
||||||
-- the event bus behave exactly as a GUI save.
|
if code = "s":
|
||||||
ensures: PostPersisted()
|
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 {
|
rule EditorMode {
|
||||||
when: TuiKeyPressed(code, modifiers)
|
when: TuiKeyPressed(code, modifiers)
|
||||||
requires: code = "e"
|
requires: code = "e"
|
||||||
requires: modifiers = "ctrl"
|
requires: modifiers = "ctrl"
|
||||||
-- ctrl+e toggles between the syntax-highlighted editor (the default
|
requires: TuiState.editing_post
|
||||||
-- mode when opening a post) and the read-only rendered-Markdown
|
ensures: TuiState.editor_preview = not TuiState.editor_preview
|
||||||
-- preview (rendered through tui-markdown so headings/emphasis/lists
|
-- toggles between the syntax-highlighted editor (the default
|
||||||
-- are styled); keys in preview never edit content, and esc returns
|
-- when opening a post) and the read-only rendered-Markdown
|
||||||
-- to the sidebar from either mode.
|
-- preview (tui-markdown: headings/emphasis/lists styled);
|
||||||
ensures: PreviewToggled()
|
-- keys in preview never edit content
|
||||||
}
|
}
|
||||||
|
|
||||||
rule CodeEditorWrapping {
|
rule EditorEscape {
|
||||||
when: TuiState.editing_post.updated()
|
when: TuiKeyPressed(code, _)
|
||||||
-- The post editor renders the buffer with syntax highlighting
|
requires: code = "esc"
|
||||||
-- (markdown with [[macro]] accents for posts, HTML+Liquid for
|
requires: TuiState.focus = editor
|
||||||
-- templates, Lua for scripts), soft-wraps long lines to the
|
ensures: TuiState.focus = sidebar
|
||||||
-- viewport width, keeps the cursor's visual row visible while
|
-- esc returns to the sidebar from either editor mode
|
||||||
-- scrolling, and highlights the cursor's source line. There is no
|
|
||||||
-- line-number gutter.
|
|
||||||
ensures: TuiState.updated()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rule AiQuickAction {
|
rule AiQuickAction {
|
||||||
when: TuiKeyPressed(code, modifiers)
|
when: TuiKeyPressed(code, modifiers)
|
||||||
requires: code = "g"
|
requires: code = "g"
|
||||||
requires: modifiers = "ctrl"
|
requires: modifiers = "ctrl"
|
||||||
-- One-shot AI post analysis (title/excerpt suggestions). Gated by
|
requires: TuiState.editing_post
|
||||||
-- airplane mode: without a local endpoint the TUI shows a status
|
ensures:
|
||||||
-- message instead of calling out.
|
if active_endpoint_configured:
|
||||||
ensures: AiSuggestionsRequestedOrRefused()
|
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 {
|
rule ProjectSwitcher {
|
||||||
when: TuiKeyPressed(code, _)
|
when: TuiKeyPressed(code, modifiers)
|
||||||
requires: code = "p"
|
requires: code = "p"
|
||||||
-- "p" opens the projects overlay: all projects from the caching
|
requires: modifiers != "ctrl"
|
||||||
-- database with the active one marked; enter activates the selected
|
requires: TuiState.focus = sidebar
|
||||||
-- project (BDS.Projects.set_active_project) and reloads the sidebar.
|
ensures: ProjectsOverlayOpened(projects: all_projects(), active: active_project())
|
||||||
-- "o" switches to a folder-path prompt with bash-style tab
|
-- all projects from the caching database, active one marked
|
||||||
-- 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
|
rule ProjectOverlayActivate {
|
||||||
-- offered, dotfolders only for a dot-prefixed input, and a leading
|
when: ProjectsOverlaySelectionConfirmed(project)
|
||||||
-- "~" expands to the home directory. A typed path is validated
|
ensures: ActiveProjectChanged(project)
|
||||||
-- to be an existing directory, then opened as a project
|
-- BDS.Projects.set_active_project
|
||||||
-- (BDS.Projects.create_project with data_path, named after the
|
ensures: SidebarReloaded()
|
||||||
-- 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
|
rule ProjectOverlayFolderPrompt {
|
||||||
-- paths keep the prompt open and report the error.
|
when: TuiKeyPressed(code, _)
|
||||||
ensures: ProjectSwitchedOrOpened()
|
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 {
|
rule SidebarSearch {
|
||||||
when: TuiKeyPressed(code, _)
|
when: TuiKeyPressed(code, _)
|
||||||
requires: code = "/"
|
requires: code = "/"
|
||||||
-- "/" in sidebar focus opens a vi-style search prompt in the status
|
requires: TuiState.focus = sidebar
|
||||||
-- line that live-filters the current view through the same
|
ensures: SearchPromptOpened()
|
||||||
-- BDS.UI.Sidebar filter params the GUI sidebar uses (posts, pages,
|
-- vi-style search prompt in the status line
|
||||||
-- 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
rule CommandPrompt {
|
||||||
when: TuiKeyPressed(code, _)
|
when: TuiKeyPressed(code, _)
|
||||||
requires: code = ":"
|
requires: code = ":"
|
||||||
-- Vi-style prompt: ":" opens a filterable list of the parameterless
|
requires: TuiState.focus = sidebar
|
||||||
-- Blog-menu shell commands (metadata diff, validate site, force
|
ensures: CommandListOpened(commands: parameterless_shell_commands())
|
||||||
-- render, rebuilds, reindex, translations, duplicates, upload,
|
-- vi-style prompt: filterable list of the parameterless
|
||||||
-- browser preview URL) and runs the selection through the same
|
-- Blog-menu shell commands (metadata diff, validate site,
|
||||||
-- BDS.Desktop.ShellCommands backend as the GUI menu; typing ":?"
|
-- force render, rebuilds, reindex, translations, duplicates,
|
||||||
-- shows the full list as help. Overlays clear the cells beneath
|
-- upload, browser preview URL) — the same
|
||||||
-- them. Commands whose follow-up UI is GUI-only (validate
|
-- BDS.Desktop.ShellCommands backend as the GUI menu. ":?"
|
||||||
-- translations, find duplicates) are marked. Queued tasks report
|
-- shows the full list as help. Commands whose follow-up UI is
|
||||||
-- progress in the status line until all tasks finish.
|
-- GUI-only (validate translations, find duplicates) are marked.
|
||||||
ensures: CommandListShownOrExecuted()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
rule SettingsPanel {
|
||||||
when: TuiKeyPressed(code, _)
|
when: TuiKeyPressed(code, _)
|
||||||
requires: code = "6"
|
requires: code = "6"
|
||||||
-- "6" opens the settings panel (issue #29), matching the GUI panel
|
requires: TuiState.focus = sidebar
|
||||||
-- numbering: the sidebar lists the same preference sections as the
|
-- "6" matches the GUI panel numbering
|
||||||
-- settings backends (Project, Editor, Content, AI, Technology,
|
ensures: TuiState.view = settings
|
||||||
-- Publishing, Data, MCP). The GUI-only Style tab is intentionally
|
ensures: SettingsSectionsListed(sections: config.tui_settings_sections)
|
||||||
-- absent. Enter on a section opens a section-specific editor in the
|
-- enter on a section opens a section-specific editor in the
|
||||||
-- main area: a generic typed-field form from BDS.UI.SettingsForm.
|
-- 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
rule TagsPanel {
|
||||||
when: TuiKeyPressed(code, _)
|
when: TuiKeyPressed(code, _)
|
||||||
requires: code = "5"
|
requires: code = "5"
|
||||||
-- "5" opens the tags view (issue #34), matching the GUI panel
|
requires: TuiState.focus = sidebar
|
||||||
-- numbering. The sidebar lists the same three sections as the GUI
|
-- "5" matches the GUI panel numbering
|
||||||
-- tags editor (Tag Cloud, Create / Edit, Merge Tags — from
|
ensures: TuiState.view = tags
|
||||||
-- BDS.UI.Sidebar's tags nav view); enter opens the section in the
|
ensures: TagsSectionsListed()
|
||||||
-- main area, all backed by BDS.UI.TagsPanel over the same BDS.Tags
|
-- the same three sections as the GUI tags editor (Tag Cloud,
|
||||||
-- operations as the GUI editor. Cloud lists tags with their post
|
-- Create / Edit, Merge Tags — from BDS.UI.Sidebar's tags nav
|
||||||
-- usage counts ordered by usage; manage is alphabetical with "n"
|
-- view); enter opens the section in the main area, all backed
|
||||||
-- (create prompt), enter (rename prompt), "c" (cycle colour through
|
-- by BDS.UI.TagsPanel over the same BDS.Tags operations as the
|
||||||
-- the shared presets), "t" (cycle post template), "d" then "y"
|
-- GUI editor. Cloud lists tags with post usage counts ordered
|
||||||
-- (delete with post-count confirmation; any other key cancels) and
|
-- by usage; manage is alphabetical. Text prompts live in the
|
||||||
-- "s" (sync tags from post tags); merge marks tags with space and
|
-- status line; esc cancels the prompt, then closes the panel.
|
||||||
-- "m" merges the marked tags into the selected one (at least two,
|
-- Domain tag events reload an open panel, keeping it in sync
|
||||||
-- target included). Text prompts live in the status line; esc
|
-- with GUI and CLI writes.
|
||||||
-- 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
rule GitPanel {
|
||||||
when: TuiKeyPressed(code, _)
|
when: TuiKeyPressed(code, _)
|
||||||
requires: code = "7"
|
requires: code = "7"
|
||||||
-- "7" opens the git panel (issue #30) for content sync: the sidebar
|
requires: TuiState.focus = sidebar
|
||||||
-- lists the changed files from git status (code + path, branch with
|
-- "7" matches the GUI panel numbering; content sync panel
|
||||||
-- ahead/behind counts in the title) with the commit history in its
|
ensures: TuiState.view = git
|
||||||
-- lower half, like the GUI (short hash + subject; ↑ marks commits
|
ensures: GitPanelOpened()
|
||||||
-- that still need a push, ↓ remote-only ones), the main area shows a
|
-- sidebar: changed files from git status (code + path, branch
|
||||||
-- scrollable whole-folder diff (staged + unstaged, capped) paged
|
-- with ahead/behind counts in the title), commit history in
|
||||||
-- with pgup/pgdn; enter on a file jumps the diff to it. "c" opens a
|
-- the lower half like the GUI (short hash + subject; ↑ marks
|
||||||
-- status-line commit prompt (git add -A + commit, empty messages
|
-- commits that still need a push, ↓ remote-only ones).
|
||||||
-- rejected), "u" pulls (ff-only) and "s" pushes — both run off the
|
-- Main area: scrollable whole-folder diff (staged + unstaged,
|
||||||
-- UI process and report back as a status toast. Every action is
|
-- capped) paged with pgup/pgdn. Every action is BDS.Git, the
|
||||||
-- BDS.Git, the same backend as the GUI git panel. A project folder
|
-- same backend as the GUI git panel.
|
||||||
-- that is not a git repository shows a message and refuses the
|
|
||||||
-- actions.
|
|
||||||
ensures: GitPanelShownOrSynced()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
rule ReportPanels {
|
||||||
when: ShellTaskCompleted(route)
|
when: ShellTaskCompleted(route)
|
||||||
requires: route = "metadata_diff" or route = "site_validation"
|
requires: route = "metadata_diff" or route = "site_validation"
|
||||||
-- Completed metadata diff and site validation tasks open a
|
requires: task_completed_in_current_session(route)
|
||||||
-- scrollable report panel showing the differences. Enter applies
|
-- reports completed before this session started never pop up
|
||||||
-- the whole report — metadata diff repairs all items from the
|
ensures: ReportPanelOpened(route)
|
||||||
-- filesystem (file → db) and imports orphan files; site validation
|
-- scrollable report panel showing the differences
|
||||||
-- 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
rule MultiClientSync {
|
||||||
when: TuiEventReceived(entity, entity_id, action)
|
when: TuiEventReceived(entity, entity_id, action)
|
||||||
-- Domain events refresh the sidebar; a post deleted elsewhere closes
|
-- Keeps TUI sessions synchronized with GUI clients and pipeline
|
||||||
-- the open editor. Keeps TUI sessions synchronized with GUI clients
|
-- ingestion.
|
||||||
-- and pipeline ingestion.
|
ensures: SidebarReloaded()
|
||||||
ensures: TuiState.updated()
|
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 {
|
rule ServerSideLocale {
|
||||||
when: TuiSettingsChanged(key)
|
when: TuiSettingsChanged(key)
|
||||||
requires: key = "ui.language"
|
requires: key = "ui.language"
|
||||||
-- The TUI re-reads the server-side UI language and re-renders; all
|
|
||||||
-- clients always share one language.
|
|
||||||
ensures: TuiRelocalized()
|
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