diff --git a/specs/frontmatter.allium b/specs/frontmatter.allium index 9850264..96259d2 100644 --- a/specs/frontmatter.allium +++ b/specs/frontmatter.allium @@ -66,10 +66,11 @@ rule WritePostFile { -- ============================================================================ value MediaSidecar { - -- File path: media/{id}.md - -- Binary file at: media/{filename} + -- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta) + -- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext} + -- Format: YAML-like key-value (hand-built, not gray-matter frontmatter) + -- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path id: String -- UUID v4 - filename: String -- Generated filename (e.g., photo_1234567890.jpg) original_name: String -- Original uploaded filename mime_type: String size: Integer -- Bytes @@ -87,7 +88,7 @@ value MediaSidecar { invariant MediaSidecarLayout { for m in Media: - m.sidecar_path = format("media/{id}.md", id: m.id) + m.sidecar_path = format("{binary_path}.meta", binary_path: m.file_path) } -- ============================================================================ @@ -301,10 +302,11 @@ invariant ConditionalPostFields { } invariant RequiredMediaFields { - -- These fields are ALWAYS written for media + -- These fields are ALWAYS written for media sidecars + -- Note: 'filename' is NOT a sidecar field — it is the binary path itself for m in Media: required_fields(m) = { - id, filename, original_name, mime_type, size, + id, original_name, mime_type, size, created_at, updated_at, tags } } diff --git a/specs/i18n.allium b/specs/i18n.allium index 8dfbe1d..bca240e 100644 --- a/specs/i18n.allium +++ b/specs/i18n.allium @@ -36,7 +36,8 @@ invariant LanguageNormalization { invariant MenuTranslations { -- Menu item labels are separately translatable - -- translateMenu() uses render locale, not UI locale + -- translateMenu() uses UI locale (system/OS locale), NOT render locale + -- This follows the OS convention: menus match the system language } invariant RenderTranslations { diff --git a/specs/media.allium b/specs/media.allium index 58d60a6..a6bdb3e 100644 --- a/specs/media.allium +++ b/specs/media.allium @@ -35,7 +35,7 @@ entity Media { file_path: String sidecar_path: String checksum: String? - tags: Set + tags: List created_at: Timestamp updated_at: Timestamp @@ -85,7 +85,7 @@ rule ImportMedia { width: detect_width(source_file), height: detect_height(source_file), file_path: dest, - tags: {} + tags: [] ) ensures: FileCopied(source_file, dest) ensures: SidecarWritten(media) diff --git a/specs/media_processing.allium b/specs/media_processing.allium index 846975d..4214023 100644 --- a/specs/media_processing.allium +++ b/specs/media_processing.allium @@ -15,75 +15,111 @@ use "./search.allium" as search -- ============================================================================ value MediaFileLayout { - -- Binary assets stored in: media/{filename} - -- Sidecar metadata in: media/{id}.md - -- Thumbnails in: thumbnails/{id}.webp - -- Thumbnail source filename: thumbnails/{id}_source.{ext} - binary_path: String -- media/{filename} - sidecar_path: String -- media/{id}.md - thumbnail_path: String -- thumbnails/{id}.webp - thumbnail_source_path: String -- thumbnails/{id}_source.{ext} + -- Binary assets stored in: media/{YYYY}/{MM}/{uuid}.{ext} + -- Sidecar metadata in: {binary_path}.meta + -- Thumbnails in: thumbnails/{id[0:2]}/{id}-{size}.webp + -- (ai thumbnail is JPEG: thumbnails/{id[0:2]}/{id}-ai.jpg) + binary_path: String -- media/{YYYY}/{MM}/{uuid}.{ext} + sidecar_path: String -- {binary_path}.meta + thumbnail_small: String -- thumbnails/{prefix}/{id}-small.webp + thumbnail_medium: String -- thumbnails/{prefix}/{id}-medium.webp + thumbnail_large: String -- thumbnails/{prefix}/{id}-large.webp + thumbnail_ai: String -- thumbnails/{prefix}/{id}-ai.jpg } invariant MediaFileNaming { -- Original filename is preserved in original_name field - -- Stored filename is generated: {timestamp}_{random}.{ext} - -- Example: 1710512345678_abc123.jpg + -- Stored filename uses UUID v4: {uuid}.{ext} + -- Example: a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg for m in Media: - m.filename = format("{timestamp}_{random}.{ext}", - timestamp: m.created_at.milliseconds, - random: generate_random_string(6), + m.filename = format("{uuid}.{ext}", + uuid: generate_uuid_v4(), ext: file_extension(m.original_name)) } +invariant ThumbnailPathBucketing { + -- Thumbnails are bucketed by first 2 chars of media ID + -- This avoids filesystem slowdowns from too many files in one directory + for m in Media: + let prefix = m.id[0:2] + m.thumbnails.small = format("thumbnails/{prefix}/{id}-small.webp", + prefix: prefix, id: m.id) + m.thumbnails.medium = format("thumbnails/{prefix}/{id}-medium.webp", + prefix: prefix, id: m.id) + m.thumbnails.large = format("thumbnails/{prefix}/{id}-large.webp", + prefix: prefix, id: m.id) + m.thumbnails.ai = format("thumbnails/{prefix}/{id}-ai.jpg", + prefix: prefix, id: m.id) +} + -- ============================================================================ -- THUMBNAIL GENERATION -- ============================================================================ config { - -- Thumbnail configuration - thumbnail_width: Integer = 400 - thumbnail_height: Integer = 300 - thumbnail_fit: String = "cover" -- "cover", "contain", "fill" - thumbnail_quality: Integer = 80 -- WEBP quality 1-100 - thumbnail_format: String = "webp" -- Output format + -- Four thumbnail sizes generated per image + thumbnail_small_width: Integer = 150 + thumbnail_medium_width: Integer = 400 + thumbnail_large_width: Integer = 800 + thumbnail_ai_size: Integer = 448 -- 448x448 square crop, JPEG + thumbnail_quality: Integer = 80 -- WebP quality, hardcoded + thumbnail_format: String = "webp" -- All sizes except AI + thumbnail_ai_format: String = "jpeg" -- AI thumbnail only } -rule GenerateThumbnail { +rule GenerateThumbnails { when: MediaImported(media) requires: is_image(media.mime_type) - -- Generate thumbnail from source image + -- Generate all four thumbnail sizes ensures: ThumbnailGenerated( source: media.file_path, - destination: media.thumbnail_path, - width: config.thumbnail_width, - height: config.thumbnail_height, - fit: config.thumbnail_fit, + destination: media.thumbnails.small, + width: config.thumbnail_small_width, quality: config.thumbnail_quality, format: config.thumbnail_format ) - ensures: ThumbnailSourceSaved( + ensures: ThumbnailGenerated( source: media.file_path, - destination: media.thumbnail_source_path + destination: media.thumbnails.medium, + width: config.thumbnail_medium_width, + quality: config.thumbnail_quality, + format: config.thumbnail_format + ) + ensures: ThumbnailGenerated( + source: media.file_path, + destination: media.thumbnails.large, + width: config.thumbnail_large_width, + quality: config.thumbnail_quality, + format: config.thumbnail_format + ) + ensures: ThumbnailGenerated( + source: media.file_path, + destination: media.thumbnails.ai, + size: config.thumbnail_ai_size, + format: config.thumbnail_ai_format ) } -- Thumbnail generation algorithm value ThumbnailGeneration { - -- 1. Load source image with EXIF orientation - -- 2. Auto-rotate based on EXIF orientation tag - -- 3. Resize using "cover" fit (crop to fill target dimensions) - -- 4. Encode as WEBP with specified quality - -- 5. Write to thumbnails/{id}.webp - - -- Preserve original for source thumbnail - -- This allows regenerating thumbnails at different sizes later + -- 1. Load source image + -- 2. Read raw image dimensions from header (not EXIF-orientation-aware) + -- 3. Resize: small/medium/large preserve aspect ratio (width-constrained) + -- AI thumbnail is a 448x448 center crop + -- 4. Encode as WebP (quality 80) for small/medium/large + -- Encode as JPEG for AI thumbnail + -- 5. Write to bucketed thumbnail path: thumbnails/{id[0:2]}/{id}-{size}.{ext} + -- + -- No _source copy is made. Thumbnails regenerated from the original binary. } invariant ThumbnailExifHandling { - -- EXIF orientation must be respected during thumbnail generation - -- Photos taken with phone cameras often have incorrect orientation - -- without proper EXIF handling + -- TypeScript app reads raw image header dimensions + -- It does NOT apply EXIF orientation correction during thumbnail generation + -- Width/height stored in DB are the raw header values + @guidance + -- The Rust implementation should match this: raw header dimensions + -- EXIF auto-rotation can be added later as an enhancement } -- ============================================================================ @@ -97,51 +133,53 @@ value ImageProcessing { "image/webp", "image/tiff", "image/bmp", "image/heic", "image/heif" } - + -- Output formats: output_formats: Set = { - "image/webp", -- Primary output - "image/jpeg", -- Fallback for non-WEBP browsers (not used in Rust version) - "image/png" -- For images with transparency + "image/webp", -- Primary output for thumbnails + "image/jpeg" -- AI thumbnail only } - + -- Processing rules: - -- 1. All images are converted to WEBP for thumbnails - -- 2. Original format is preserved for full-size assets - -- 3. EXIF data is stripped from thumbnails (privacy) - -- 4. EXIF orientation is applied during processing + -- 1. All thumbnails (except AI) are encoded as WebP quality 80 + -- 2. AI thumbnail is encoded as JPEG (for vision model compatibility) + -- 3. Original format is preserved for full-size assets (no conversion) + -- 4. EXIF data is not stripped (thumbnails are re-encoded, so EXIF is naturally absent) } rule ProcessImageMetadata { when: MediaImported(media) - -- Extract image metadata - ensures: media.width = extract_width(source_file) - ensures: media.height = extract_height(source_file) - ensures: media.mime_type = detect_mime_type(source_file) + -- Extract image metadata from raw file header + ensures: media.width = extract_width_from_header(source_file) + ensures: media.height = extract_height_from_header(source_file) + ensures: media.mime_type = detect_mime_from_extension(source_file) ensures: media.size = file_size(source_file) } +invariant MimeDetection { + -- MIME type is detected from file extension, not from file content/magic bytes + -- Extension mapping: .jpg/.jpeg -> image/jpeg, .png -> image/png, etc. +} + -- ============================================================================ -- MEDIA TRANSLATION FILES -- ============================================================================ value MediaTranslationFile { - -- File path: media/{id}/{language}.md - -- Format: YAML frontmatter with localized metadata + -- File path: {binary_path}.{language}.meta + -- Format: YAML-like key-value sidecar (same as canonical sidecar) translation_for: String -- Canonical media ID language: String -- ISO 639-1 code title: String? alt: String? caption: String? - created_at: Timestamp - updated_at: Timestamp } invariant MediaTranslationFileLayout { for t in MediaTranslations: - -- Stored in subdirectory per media item - t.file_path = format("media/{id}/{lang}.md", - id: t.translation_for, + -- Translation sidecars sit next to the binary, with language suffix + t.file_path = format("{binary_path}.{lang}.meta", + binary_path: t.media.file_path, lang: t.language) } @@ -151,25 +189,24 @@ invariant MediaTranslationFileLayout { rule ImportMedia { when: ImportMediaRequested(source_path, project) - -- 1. Validate file type (must be image) - -- 2. Generate unique filename - -- 3. Copy to media/{filename} - -- 4. Generate thumbnail - -- 5. Create sidecar file with metadata + -- 1. Validate file type (must be supported image) + -- 2. Generate UUID v4 filename + -- 3. Copy to media/{YYYY}/{MM}/{uuid}.{ext} + -- 4. Write sidecar {binary_path}.meta + -- 5. Generate four thumbnail sizes -- 6. Index for search (FTS5) - -- 7. Generate embedding (if enabled) ensures: media/Media.created( - filename: generate_filename(), + filename: generate_uuid_v4_filename(source_path), original_name: basename(source_path), - mime_type: detect_mime_type(source_path), + mime_type: detect_mime_from_extension(source_path), size: file_size(source_path), - width: extract_width(source_path), - height: extract_height(source_path), - file_path: format("media/{filename}", filename: generated_filename), - sidecar_path: format("media/{id}.md", id: media_id), + width: extract_width_from_header(source_path), + height: extract_height_from_header(source_path), + file_path: format("media/{yyyy}/{mm}/{uuid}.{ext}"), + sidecar_path: format("media/{yyyy}/{mm}/{uuid}.{ext}.meta"), checksum: sha256(source_path) ) - ensures: ThumbnailGenerated(media_id) + ensures: ThumbnailsGenerated(media_id) ensures: SearchIndexUpdated(media_id) } @@ -198,14 +235,17 @@ rule DeleteMedia { when: DeleteMediaRequested(media) -- 1. Remove from database -- 2. Delete binary file - -- 3. Delete sidecar file - -- 4. Delete thumbnail files - -- 5. Remove from search index - -- 6. Remove from all post links + -- 3. Delete sidecar file ({binary_path}.meta) + -- 4. Delete all four thumbnail files + -- 5. Delete translation sidecars ({binary_path}.{lang}.meta) + -- 6. Remove from search index + -- 7. Remove from all post links ensures: FileDeleted(media.file_path) ensures: FileDeleted(media.sidecar_path) - ensures: FileDeleted(media.thumbnail_path) - ensures: FileDeleted(media.thumbnail_source_path) + ensures: FileDeleted(media.thumbnails.small) + ensures: FileDeleted(media.thumbnails.medium) + ensures: FileDeleted(media.thumbnails.large) + ensures: FileDeleted(media.thumbnails.ai) ensures: SearchIndexRemoved(media) ensures: for p in media.linked_posts: @@ -221,7 +261,7 @@ rule ValidateMedia { -- Check for: -- 1. Missing binary files -- 2. Missing sidecar files - -- 3. Missing thumbnails + -- 3. Missing thumbnails (all 4 sizes) -- 4. Corrupted image files -- 5. Orphan media (not linked to any post) for m in project.media: @@ -229,8 +269,14 @@ rule ValidateMedia { ensures: ValidationIssueReported(m, "missing_binary") if not file_exists(m.sidecar_path): ensures: ValidationIssueReported(m, "missing_sidecar") - if not file_exists(m.thumbnail_path): - ensures: ValidationIssueReported(m, "missing_thumbnail") + if not file_exists(m.thumbnails.small): + ensures: ValidationIssueReported(m, "missing_thumbnail_small") + if not file_exists(m.thumbnails.medium): + ensures: ValidationIssueReported(m, "missing_thumbnail_medium") + if not file_exists(m.thumbnails.large): + ensures: ValidationIssueReported(m, "missing_thumbnail_large") + if not file_exists(m.thumbnails.ai): + ensures: ValidationIssueReported(m, "missing_thumbnail_ai") if not is_valid_image(m.file_path): ensures: ValidationIssueReported(m, "corrupted") if not exists (p in Posts where m in p.linked_media): @@ -238,13 +284,15 @@ rule ValidateMedia { } -- ============================================================================ --- MEDIA EXPORT/EXPORT RULES +-- MEDIA SIDECAR FORMAT -- ============================================================================ invariant MediaSidecarFormat { - -- Sidecar files use YAML frontmatter - -- Same structure as Media entity fields + -- Sidecar files use YAML-like key-value format (hand-built, not gray-matter) + -- Path: {binary_path}.meta -- Only truthy fields are written (except required fields) + -- Fields: title, alt, caption, author, tags, language, linkedPostIds + -- Note: 'filename' is NOT written to sidecar (it is the binary filename itself) } -- ============================================================================ @@ -252,22 +300,10 @@ invariant MediaSidecarFormat { -- ============================================================================ config { - -- Image optimization settings - max_original_size: Integer = 20971520 -- 20 MB in bytes - max_width: Integer = 4096 - max_height: Integer = 4096 - strip_exif: Boolean = true -- Strip EXIF from thumbnails - preserve_color_profile: Boolean = false -- Don't preserve ICC profiles -} - -rule OptimizeMedia { - when: ImportMediaRequested(source_path, project) - requires: file_size(source_path) <= config.max_original_size - -- Original files are stored as-is (no compression) - -- Only thumbnails are optimized - @guidance - -- Media entity is created by ImportMedia rule - -- This rule documents the size constraint + -- No file size limit on import (TypeScript app has no max_original_size check) + -- Original files are stored as-is (no compression, no resize) + -- Only thumbnails are generated from the original + strip_exif: Boolean = false -- Not explicitly stripped; re-encoding naturally omits it } -- ============================================================================ @@ -278,7 +314,6 @@ rule IndexMediaForSearch { when: SearchIndexUpdated(media: media/Media) -- Index fields: title, alt, caption, original_name, tags -- Plus all translation titles, alts, and captions - -- Note: media.translations not available in this context let all_text = concat( media.title, media.alt, diff --git a/specs/post.allium b/specs/post.allium index 95b814c..3782f8a 100644 --- a/specs/post.allium +++ b/specs/post.allium @@ -53,8 +53,8 @@ entity Post { template_slug: String? file_path: String checksum: String? - tags: Set - categories: Set + tags: List + categories: List created_at: Timestamp updated_at: Timestamp published_at: Timestamp? @@ -112,8 +112,8 @@ rule CreatePost { status: draft, author: author, language: language, - tags: tags ?? {}, - categories: categories ?? {}, + tags: tags ?? [], + categories: categories ?? [], template_slug: template_slug, do_not_translate: false, file_path: "" @@ -184,7 +184,6 @@ invariant DateBasedFileLayout { slug: post.slug) } --- Slug freezing behaviour matches TypeScript app: --- slug changes allowed on draft posts even if previously published, --- frozen only while status = published (is_slug_frozen = published_at != null --- is the guard, but status must also be published for the file to exist) +-- Slug freeze: once published_at is set, the slug is permanently frozen. +-- This matches TypeScript behaviour: is_slug_frozen = published_at != null +-- Even if the post reverts to draft, the slug cannot be changed. diff --git a/specs/project.allium b/specs/project.allium index f03f063..00a353f 100644 --- a/specs/project.allium +++ b/specs/project.allium @@ -61,8 +61,10 @@ rule SetActiveProject { rule DeleteProject { when: DeleteProjectRequested(project) - requires: project != default_project - -- The default project cannot be deleted + requires: project.id != "default" + -- The default project (id='default') cannot be deleted + requires: project.is_active = false + -- The currently active project cannot be deleted ensures: not exists project @guidance -- deleteProjectWithData removes DB rows + internal directory @@ -70,5 +72,12 @@ rule DeleteProject { } config { + default_project_id: String = "default" default_project_name: String = "My Blog" } + +invariant DefaultProjectExists { + -- A project with id='default' always exists + -- It is created on first launch if missing + exists p in Projects where p.id = "default" +} diff --git a/specs/script.allium b/specs/script.allium index 30aea14..d4aeec5 100644 --- a/specs/script.allium +++ b/specs/script.allium @@ -43,6 +43,7 @@ invariant ScriptFileLayout { rule CreateScript { when: CreateScriptRequested(title, kind, content, entrypoint) let slug = slugify(title) + -- Creates a draft script: content stored in DB, no file written yet ensures: Script.created( slug: slug, title: title, @@ -56,6 +57,26 @@ rule CreateScript { ) } +rule CreateAndPublishScript { + -- Alternative creation path: create + immediately publish (file written) + -- TypeScript: createScript() does this in one step + when: CreateAndPublishScriptRequested(title, kind, content, entrypoint) + let slug = slugify(title) + requires: ValidateScript(content) = valid + ensures: Script.created( + slug: slug, + title: title, + kind: kind, + content: null, + entrypoint: entrypoint ?? "render", + status: published, + enabled: true, + version: 1, + file_path: format("scripts/{slug}.lua", slug: slug) + ) + ensures: ScriptFileWritten(script) +} + rule PublishScript { when: PublishScriptRequested(script) requires: ValidateScript(script.content) = valid diff --git a/specs/task.allium b/specs/task.allium index 6bfcfa6..24e29a1 100644 --- a/specs/task.allium +++ b/specs/task.allium @@ -4,10 +4,12 @@ -- Distilled from: src/main/engine/TaskManager.ts entity Task { - title: String + name: String status: pending | running | completed | failed | cancelled progress: Decimal? -- 0.0..1.0 message: String? + group_id: String? -- Optional task grouping + group_name: String? created_at: Timestamp transitions status { @@ -35,9 +37,9 @@ invariant FifoQueue { } rule SubmitTask { - when: SubmitTaskRequested(title, work) + when: SubmitTaskRequested(name, work) let running_tasks = Tasks where status = running - ensures: Task.created(title: title, status: pending) + ensures: Task.created(name: name, status: pending) ensures: if running_tasks.count < config.max_concurrent: TaskStarted(task, work) @@ -79,8 +81,8 @@ invariant ProgressThrottled { -- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts) rule RegisterExternalTask { - when: RegisterExternalTaskRequested(title) - ensures: Task.created(title: title, status: running) + when: RegisterExternalTaskRequested(name) + ensures: Task.created(name: name, status: running) @guidance -- External tasks are not managed by the queue -- The caller is responsible for updating status diff --git a/specs/template.allium b/specs/template.allium index d2bb620..7f93b59 100644 --- a/specs/template.allium +++ b/specs/template.allium @@ -48,6 +48,7 @@ invariant TemplateFileLayout { rule CreateTemplate { when: CreateTemplateRequested(title, kind, content) let slug = slugify(title) + -- Creates a draft template: content stored in DB, no file written yet ensures: Template.created( slug: slug, title: title, @@ -60,6 +61,25 @@ rule CreateTemplate { ) } +rule CreateAndPublishTemplate { + -- Alternative creation path: create + immediately publish (file written) + -- TypeScript: createTemplate() does this in one step + when: CreateAndPublishTemplateRequested(title, kind, content) + let slug = slugify(title) + requires: ValidateLiquid(content) = valid + ensures: Template.created( + slug: slug, + title: title, + kind: kind, + content: null, + status: published, + enabled: true, + version: 1, + file_path: format("templates/{slug}.liquid", slug: slug) + ) + ensures: TemplateFileWritten(template) +} + rule UpdateTemplate { when: UpdateTemplateRequested(template, changes) ensures: TemplateFieldsUpdated(template, changes)