chore: tended the spec against the bDS app to fix contradictions
This commit is contained in:
@@ -66,10 +66,11 @@ rule WritePostFile {
|
|||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
value MediaSidecar {
|
value MediaSidecar {
|
||||||
-- File path: media/{id}.md
|
-- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta)
|
||||||
-- Binary file at: media/{filename}
|
-- 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
|
id: String -- UUID v4
|
||||||
filename: String -- Generated filename (e.g., photo_1234567890.jpg)
|
|
||||||
original_name: String -- Original uploaded filename
|
original_name: String -- Original uploaded filename
|
||||||
mime_type: String
|
mime_type: String
|
||||||
size: Integer -- Bytes
|
size: Integer -- Bytes
|
||||||
@@ -87,7 +88,7 @@ value MediaSidecar {
|
|||||||
|
|
||||||
invariant MediaSidecarLayout {
|
invariant MediaSidecarLayout {
|
||||||
for m in Media:
|
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 {
|
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:
|
for m in Media:
|
||||||
required_fields(m) = {
|
required_fields(m) = {
|
||||||
id, filename, original_name, mime_type, size,
|
id, original_name, mime_type, size,
|
||||||
created_at, updated_at, tags
|
created_at, updated_at, tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ invariant LanguageNormalization {
|
|||||||
|
|
||||||
invariant MenuTranslations {
|
invariant MenuTranslations {
|
||||||
-- Menu item labels are separately translatable
|
-- 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 {
|
invariant RenderTranslations {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ entity Media {
|
|||||||
file_path: String
|
file_path: String
|
||||||
sidecar_path: String
|
sidecar_path: String
|
||||||
checksum: String?
|
checksum: String?
|
||||||
tags: Set<String>
|
tags: List<String>
|
||||||
created_at: Timestamp
|
created_at: Timestamp
|
||||||
updated_at: Timestamp
|
updated_at: Timestamp
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ rule ImportMedia {
|
|||||||
width: detect_width(source_file),
|
width: detect_width(source_file),
|
||||||
height: detect_height(source_file),
|
height: detect_height(source_file),
|
||||||
file_path: dest,
|
file_path: dest,
|
||||||
tags: {}
|
tags: []
|
||||||
)
|
)
|
||||||
ensures: FileCopied(source_file, dest)
|
ensures: FileCopied(source_file, dest)
|
||||||
ensures: SidecarWritten(media)
|
ensures: SidecarWritten(media)
|
||||||
|
|||||||
@@ -15,75 +15,111 @@ use "./search.allium" as search
|
|||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
value MediaFileLayout {
|
value MediaFileLayout {
|
||||||
-- Binary assets stored in: media/{filename}
|
-- Binary assets stored in: media/{YYYY}/{MM}/{uuid}.{ext}
|
||||||
-- Sidecar metadata in: media/{id}.md
|
-- Sidecar metadata in: {binary_path}.meta
|
||||||
-- Thumbnails in: thumbnails/{id}.webp
|
-- Thumbnails in: thumbnails/{id[0:2]}/{id}-{size}.webp
|
||||||
-- Thumbnail source filename: thumbnails/{id}_source.{ext}
|
-- (ai thumbnail is JPEG: thumbnails/{id[0:2]}/{id}-ai.jpg)
|
||||||
binary_path: String -- media/{filename}
|
binary_path: String -- media/{YYYY}/{MM}/{uuid}.{ext}
|
||||||
sidecar_path: String -- media/{id}.md
|
sidecar_path: String -- {binary_path}.meta
|
||||||
thumbnail_path: String -- thumbnails/{id}.webp
|
thumbnail_small: String -- thumbnails/{prefix}/{id}-small.webp
|
||||||
thumbnail_source_path: String -- thumbnails/{id}_source.{ext}
|
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 {
|
invariant MediaFileNaming {
|
||||||
-- Original filename is preserved in original_name field
|
-- Original filename is preserved in original_name field
|
||||||
-- Stored filename is generated: {timestamp}_{random}.{ext}
|
-- Stored filename uses UUID v4: {uuid}.{ext}
|
||||||
-- Example: 1710512345678_abc123.jpg
|
-- Example: a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg
|
||||||
for m in Media:
|
for m in Media:
|
||||||
m.filename = format("{timestamp}_{random}.{ext}",
|
m.filename = format("{uuid}.{ext}",
|
||||||
timestamp: m.created_at.milliseconds,
|
uuid: generate_uuid_v4(),
|
||||||
random: generate_random_string(6),
|
|
||||||
ext: file_extension(m.original_name))
|
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
|
-- THUMBNAIL GENERATION
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
config {
|
config {
|
||||||
-- Thumbnail configuration
|
-- Four thumbnail sizes generated per image
|
||||||
thumbnail_width: Integer = 400
|
thumbnail_small_width: Integer = 150
|
||||||
thumbnail_height: Integer = 300
|
thumbnail_medium_width: Integer = 400
|
||||||
thumbnail_fit: String = "cover" -- "cover", "contain", "fill"
|
thumbnail_large_width: Integer = 800
|
||||||
thumbnail_quality: Integer = 80 -- WEBP quality 1-100
|
thumbnail_ai_size: Integer = 448 -- 448x448 square crop, JPEG
|
||||||
thumbnail_format: String = "webp" -- Output format
|
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)
|
when: MediaImported(media)
|
||||||
requires: is_image(media.mime_type)
|
requires: is_image(media.mime_type)
|
||||||
-- Generate thumbnail from source image
|
-- Generate all four thumbnail sizes
|
||||||
ensures: ThumbnailGenerated(
|
ensures: ThumbnailGenerated(
|
||||||
source: media.file_path,
|
source: media.file_path,
|
||||||
destination: media.thumbnail_path,
|
destination: media.thumbnails.small,
|
||||||
width: config.thumbnail_width,
|
width: config.thumbnail_small_width,
|
||||||
height: config.thumbnail_height,
|
|
||||||
fit: config.thumbnail_fit,
|
|
||||||
quality: config.thumbnail_quality,
|
quality: config.thumbnail_quality,
|
||||||
format: config.thumbnail_format
|
format: config.thumbnail_format
|
||||||
)
|
)
|
||||||
ensures: ThumbnailSourceSaved(
|
ensures: ThumbnailGenerated(
|
||||||
source: media.file_path,
|
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
|
-- Thumbnail generation algorithm
|
||||||
value ThumbnailGeneration {
|
value ThumbnailGeneration {
|
||||||
-- 1. Load source image with EXIF orientation
|
-- 1. Load source image
|
||||||
-- 2. Auto-rotate based on EXIF orientation tag
|
-- 2. Read raw image dimensions from header (not EXIF-orientation-aware)
|
||||||
-- 3. Resize using "cover" fit (crop to fill target dimensions)
|
-- 3. Resize: small/medium/large preserve aspect ratio (width-constrained)
|
||||||
-- 4. Encode as WEBP with specified quality
|
-- AI thumbnail is a 448x448 center crop
|
||||||
-- 5. Write to thumbnails/{id}.webp
|
-- 4. Encode as WebP (quality 80) for small/medium/large
|
||||||
|
-- Encode as JPEG for AI thumbnail
|
||||||
-- Preserve original for source thumbnail
|
-- 5. Write to bucketed thumbnail path: thumbnails/{id[0:2]}/{id}-{size}.{ext}
|
||||||
-- This allows regenerating thumbnails at different sizes later
|
--
|
||||||
|
-- No _source copy is made. Thumbnails regenerated from the original binary.
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant ThumbnailExifHandling {
|
invariant ThumbnailExifHandling {
|
||||||
-- EXIF orientation must be respected during thumbnail generation
|
-- TypeScript app reads raw image header dimensions
|
||||||
-- Photos taken with phone cameras often have incorrect orientation
|
-- It does NOT apply EXIF orientation correction during thumbnail generation
|
||||||
-- without proper EXIF handling
|
-- 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/webp", "image/tiff", "image/bmp",
|
||||||
"image/heic", "image/heif"
|
"image/heic", "image/heif"
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Output formats:
|
-- Output formats:
|
||||||
output_formats: Set<String> = {
|
output_formats: Set<String> = {
|
||||||
"image/webp", -- Primary output
|
"image/webp", -- Primary output for thumbnails
|
||||||
"image/jpeg", -- Fallback for non-WEBP browsers (not used in Rust version)
|
"image/jpeg" -- AI thumbnail only
|
||||||
"image/png" -- For images with transparency
|
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Processing rules:
|
-- Processing rules:
|
||||||
-- 1. All images are converted to WEBP for thumbnails
|
-- 1. All thumbnails (except AI) are encoded as WebP quality 80
|
||||||
-- 2. Original format is preserved for full-size assets
|
-- 2. AI thumbnail is encoded as JPEG (for vision model compatibility)
|
||||||
-- 3. EXIF data is stripped from thumbnails (privacy)
|
-- 3. Original format is preserved for full-size assets (no conversion)
|
||||||
-- 4. EXIF orientation is applied during processing
|
-- 4. EXIF data is not stripped (thumbnails are re-encoded, so EXIF is naturally absent)
|
||||||
}
|
}
|
||||||
|
|
||||||
rule ProcessImageMetadata {
|
rule ProcessImageMetadata {
|
||||||
when: MediaImported(media)
|
when: MediaImported(media)
|
||||||
-- Extract image metadata
|
-- Extract image metadata from raw file header
|
||||||
ensures: media.width = extract_width(source_file)
|
ensures: media.width = extract_width_from_header(source_file)
|
||||||
ensures: media.height = extract_height(source_file)
|
ensures: media.height = extract_height_from_header(source_file)
|
||||||
ensures: media.mime_type = detect_mime_type(source_file)
|
ensures: media.mime_type = detect_mime_from_extension(source_file)
|
||||||
ensures: media.size = file_size(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
|
-- MEDIA TRANSLATION FILES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
value MediaTranslationFile {
|
value MediaTranslationFile {
|
||||||
-- File path: media/{id}/{language}.md
|
-- File path: {binary_path}.{language}.meta
|
||||||
-- Format: YAML frontmatter with localized metadata
|
-- Format: YAML-like key-value sidecar (same as canonical sidecar)
|
||||||
translation_for: String -- Canonical media ID
|
translation_for: String -- Canonical media ID
|
||||||
language: String -- ISO 639-1 code
|
language: String -- ISO 639-1 code
|
||||||
title: String?
|
title: String?
|
||||||
alt: String?
|
alt: String?
|
||||||
caption: String?
|
caption: String?
|
||||||
created_at: Timestamp
|
|
||||||
updated_at: Timestamp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant MediaTranslationFileLayout {
|
invariant MediaTranslationFileLayout {
|
||||||
for t in MediaTranslations:
|
for t in MediaTranslations:
|
||||||
-- Stored in subdirectory per media item
|
-- Translation sidecars sit next to the binary, with language suffix
|
||||||
t.file_path = format("media/{id}/{lang}.md",
|
t.file_path = format("{binary_path}.{lang}.meta",
|
||||||
id: t.translation_for,
|
binary_path: t.media.file_path,
|
||||||
lang: t.language)
|
lang: t.language)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,25 +189,24 @@ invariant MediaTranslationFileLayout {
|
|||||||
|
|
||||||
rule ImportMedia {
|
rule ImportMedia {
|
||||||
when: ImportMediaRequested(source_path, project)
|
when: ImportMediaRequested(source_path, project)
|
||||||
-- 1. Validate file type (must be image)
|
-- 1. Validate file type (must be supported image)
|
||||||
-- 2. Generate unique filename
|
-- 2. Generate UUID v4 filename
|
||||||
-- 3. Copy to media/{filename}
|
-- 3. Copy to media/{YYYY}/{MM}/{uuid}.{ext}
|
||||||
-- 4. Generate thumbnail
|
-- 4. Write sidecar {binary_path}.meta
|
||||||
-- 5. Create sidecar file with metadata
|
-- 5. Generate four thumbnail sizes
|
||||||
-- 6. Index for search (FTS5)
|
-- 6. Index for search (FTS5)
|
||||||
-- 7. Generate embedding (if enabled)
|
|
||||||
ensures: media/Media.created(
|
ensures: media/Media.created(
|
||||||
filename: generate_filename(),
|
filename: generate_uuid_v4_filename(source_path),
|
||||||
original_name: basename(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),
|
size: file_size(source_path),
|
||||||
width: extract_width(source_path),
|
width: extract_width_from_header(source_path),
|
||||||
height: extract_height(source_path),
|
height: extract_height_from_header(source_path),
|
||||||
file_path: format("media/{filename}", filename: generated_filename),
|
file_path: format("media/{yyyy}/{mm}/{uuid}.{ext}"),
|
||||||
sidecar_path: format("media/{id}.md", id: media_id),
|
sidecar_path: format("media/{yyyy}/{mm}/{uuid}.{ext}.meta"),
|
||||||
checksum: sha256(source_path)
|
checksum: sha256(source_path)
|
||||||
)
|
)
|
||||||
ensures: ThumbnailGenerated(media_id)
|
ensures: ThumbnailsGenerated(media_id)
|
||||||
ensures: SearchIndexUpdated(media_id)
|
ensures: SearchIndexUpdated(media_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,14 +235,17 @@ rule DeleteMedia {
|
|||||||
when: DeleteMediaRequested(media)
|
when: DeleteMediaRequested(media)
|
||||||
-- 1. Remove from database
|
-- 1. Remove from database
|
||||||
-- 2. Delete binary file
|
-- 2. Delete binary file
|
||||||
-- 3. Delete sidecar file
|
-- 3. Delete sidecar file ({binary_path}.meta)
|
||||||
-- 4. Delete thumbnail files
|
-- 4. Delete all four thumbnail files
|
||||||
-- 5. Remove from search index
|
-- 5. Delete translation sidecars ({binary_path}.{lang}.meta)
|
||||||
-- 6. Remove from all post links
|
-- 6. Remove from search index
|
||||||
|
-- 7. Remove from all post links
|
||||||
ensures: FileDeleted(media.file_path)
|
ensures: FileDeleted(media.file_path)
|
||||||
ensures: FileDeleted(media.sidecar_path)
|
ensures: FileDeleted(media.sidecar_path)
|
||||||
ensures: FileDeleted(media.thumbnail_path)
|
ensures: FileDeleted(media.thumbnails.small)
|
||||||
ensures: FileDeleted(media.thumbnail_source_path)
|
ensures: FileDeleted(media.thumbnails.medium)
|
||||||
|
ensures: FileDeleted(media.thumbnails.large)
|
||||||
|
ensures: FileDeleted(media.thumbnails.ai)
|
||||||
ensures: SearchIndexRemoved(media)
|
ensures: SearchIndexRemoved(media)
|
||||||
ensures:
|
ensures:
|
||||||
for p in media.linked_posts:
|
for p in media.linked_posts:
|
||||||
@@ -221,7 +261,7 @@ rule ValidateMedia {
|
|||||||
-- Check for:
|
-- Check for:
|
||||||
-- 1. Missing binary files
|
-- 1. Missing binary files
|
||||||
-- 2. Missing sidecar files
|
-- 2. Missing sidecar files
|
||||||
-- 3. Missing thumbnails
|
-- 3. Missing thumbnails (all 4 sizes)
|
||||||
-- 4. Corrupted image files
|
-- 4. Corrupted image files
|
||||||
-- 5. Orphan media (not linked to any post)
|
-- 5. Orphan media (not linked to any post)
|
||||||
for m in project.media:
|
for m in project.media:
|
||||||
@@ -229,8 +269,14 @@ rule ValidateMedia {
|
|||||||
ensures: ValidationIssueReported(m, "missing_binary")
|
ensures: ValidationIssueReported(m, "missing_binary")
|
||||||
if not file_exists(m.sidecar_path):
|
if not file_exists(m.sidecar_path):
|
||||||
ensures: ValidationIssueReported(m, "missing_sidecar")
|
ensures: ValidationIssueReported(m, "missing_sidecar")
|
||||||
if not file_exists(m.thumbnail_path):
|
if not file_exists(m.thumbnails.small):
|
||||||
ensures: ValidationIssueReported(m, "missing_thumbnail")
|
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):
|
if not is_valid_image(m.file_path):
|
||||||
ensures: ValidationIssueReported(m, "corrupted")
|
ensures: ValidationIssueReported(m, "corrupted")
|
||||||
if not exists (p in Posts where m in p.linked_media):
|
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 {
|
invariant MediaSidecarFormat {
|
||||||
-- Sidecar files use YAML frontmatter
|
-- Sidecar files use YAML-like key-value format (hand-built, not gray-matter)
|
||||||
-- Same structure as Media entity fields
|
-- Path: {binary_path}.meta
|
||||||
-- Only truthy fields are written (except required fields)
|
-- 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 {
|
config {
|
||||||
-- Image optimization settings
|
-- No file size limit on import (TypeScript app has no max_original_size check)
|
||||||
max_original_size: Integer = 20971520 -- 20 MB in bytes
|
-- Original files are stored as-is (no compression, no resize)
|
||||||
max_width: Integer = 4096
|
-- Only thumbnails are generated from the original
|
||||||
max_height: Integer = 4096
|
strip_exif: Boolean = false -- Not explicitly stripped; re-encoding naturally omits it
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -278,7 +314,6 @@ rule IndexMediaForSearch {
|
|||||||
when: SearchIndexUpdated(media: media/Media)
|
when: SearchIndexUpdated(media: media/Media)
|
||||||
-- Index fields: title, alt, caption, original_name, tags
|
-- Index fields: title, alt, caption, original_name, tags
|
||||||
-- Plus all translation titles, alts, and captions
|
-- Plus all translation titles, alts, and captions
|
||||||
-- Note: media.translations not available in this context
|
|
||||||
let all_text = concat(
|
let all_text = concat(
|
||||||
media.title,
|
media.title,
|
||||||
media.alt,
|
media.alt,
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ entity Post {
|
|||||||
template_slug: String?
|
template_slug: String?
|
||||||
file_path: String
|
file_path: String
|
||||||
checksum: String?
|
checksum: String?
|
||||||
tags: Set<String>
|
tags: List<String>
|
||||||
categories: Set<String>
|
categories: List<String>
|
||||||
created_at: Timestamp
|
created_at: Timestamp
|
||||||
updated_at: Timestamp
|
updated_at: Timestamp
|
||||||
published_at: Timestamp?
|
published_at: Timestamp?
|
||||||
@@ -112,8 +112,8 @@ rule CreatePost {
|
|||||||
status: draft,
|
status: draft,
|
||||||
author: author,
|
author: author,
|
||||||
language: language,
|
language: language,
|
||||||
tags: tags ?? {},
|
tags: tags ?? [],
|
||||||
categories: categories ?? {},
|
categories: categories ?? [],
|
||||||
template_slug: template_slug,
|
template_slug: template_slug,
|
||||||
do_not_translate: false,
|
do_not_translate: false,
|
||||||
file_path: ""
|
file_path: ""
|
||||||
@@ -184,7 +184,6 @@ invariant DateBasedFileLayout {
|
|||||||
slug: post.slug)
|
slug: post.slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Slug freezing behaviour matches TypeScript app:
|
-- Slug freeze: once published_at is set, the slug is permanently frozen.
|
||||||
-- slug changes allowed on draft posts even if previously published,
|
-- This matches TypeScript behaviour: is_slug_frozen = published_at != null
|
||||||
-- frozen only while status = published (is_slug_frozen = published_at != null
|
-- Even if the post reverts to draft, the slug cannot be changed.
|
||||||
-- is the guard, but status must also be published for the file to exist)
|
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ rule SetActiveProject {
|
|||||||
|
|
||||||
rule DeleteProject {
|
rule DeleteProject {
|
||||||
when: DeleteProjectRequested(project)
|
when: DeleteProjectRequested(project)
|
||||||
requires: project != default_project
|
requires: project.id != "default"
|
||||||
-- The default project cannot be deleted
|
-- The default project (id='default') cannot be deleted
|
||||||
|
requires: project.is_active = false
|
||||||
|
-- The currently active project cannot be deleted
|
||||||
ensures: not exists project
|
ensures: not exists project
|
||||||
@guidance
|
@guidance
|
||||||
-- deleteProjectWithData removes DB rows + internal directory
|
-- deleteProjectWithData removes DB rows + internal directory
|
||||||
@@ -70,5 +72,12 @@ rule DeleteProject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config {
|
config {
|
||||||
|
default_project_id: String = "default"
|
||||||
default_project_name: String = "My Blog"
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ invariant ScriptFileLayout {
|
|||||||
rule CreateScript {
|
rule CreateScript {
|
||||||
when: CreateScriptRequested(title, kind, content, entrypoint)
|
when: CreateScriptRequested(title, kind, content, entrypoint)
|
||||||
let slug = slugify(title)
|
let slug = slugify(title)
|
||||||
|
-- Creates a draft script: content stored in DB, no file written yet
|
||||||
ensures: Script.created(
|
ensures: Script.created(
|
||||||
slug: slug,
|
slug: slug,
|
||||||
title: title,
|
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 {
|
rule PublishScript {
|
||||||
when: PublishScriptRequested(script)
|
when: PublishScriptRequested(script)
|
||||||
requires: ValidateScript(script.content) = valid
|
requires: ValidateScript(script.content) = valid
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
-- Distilled from: src/main/engine/TaskManager.ts
|
-- Distilled from: src/main/engine/TaskManager.ts
|
||||||
|
|
||||||
entity Task {
|
entity Task {
|
||||||
title: String
|
name: String
|
||||||
status: pending | running | completed | failed | cancelled
|
status: pending | running | completed | failed | cancelled
|
||||||
progress: Decimal? -- 0.0..1.0
|
progress: Decimal? -- 0.0..1.0
|
||||||
message: String?
|
message: String?
|
||||||
|
group_id: String? -- Optional task grouping
|
||||||
|
group_name: String?
|
||||||
created_at: Timestamp
|
created_at: Timestamp
|
||||||
|
|
||||||
transitions status {
|
transitions status {
|
||||||
@@ -35,9 +37,9 @@ invariant FifoQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rule SubmitTask {
|
rule SubmitTask {
|
||||||
when: SubmitTaskRequested(title, work)
|
when: SubmitTaskRequested(name, work)
|
||||||
let running_tasks = Tasks where status = running
|
let running_tasks = Tasks where status = running
|
||||||
ensures: Task.created(title: title, status: pending)
|
ensures: Task.created(name: name, status: pending)
|
||||||
ensures:
|
ensures:
|
||||||
if running_tasks.count < config.max_concurrent:
|
if running_tasks.count < config.max_concurrent:
|
||||||
TaskStarted(task, work)
|
TaskStarted(task, work)
|
||||||
@@ -79,8 +81,8 @@ invariant ProgressThrottled {
|
|||||||
|
|
||||||
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
|
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
|
||||||
rule RegisterExternalTask {
|
rule RegisterExternalTask {
|
||||||
when: RegisterExternalTaskRequested(title)
|
when: RegisterExternalTaskRequested(name)
|
||||||
ensures: Task.created(title: title, status: running)
|
ensures: Task.created(name: name, status: running)
|
||||||
@guidance
|
@guidance
|
||||||
-- External tasks are not managed by the queue
|
-- External tasks are not managed by the queue
|
||||||
-- The caller is responsible for updating status
|
-- The caller is responsible for updating status
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ invariant TemplateFileLayout {
|
|||||||
rule CreateTemplate {
|
rule CreateTemplate {
|
||||||
when: CreateTemplateRequested(title, kind, content)
|
when: CreateTemplateRequested(title, kind, content)
|
||||||
let slug = slugify(title)
|
let slug = slugify(title)
|
||||||
|
-- Creates a draft template: content stored in DB, no file written yet
|
||||||
ensures: Template.created(
|
ensures: Template.created(
|
||||||
slug: slug,
|
slug: slug,
|
||||||
title: title,
|
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 {
|
rule UpdateTemplate {
|
||||||
when: UpdateTemplateRequested(template, changes)
|
when: UpdateTemplateRequested(template, changes)
|
||||||
ensures: TemplateFieldsUpdated(template, changes)
|
ensures: TemplateFieldsUpdated(template, changes)
|
||||||
|
|||||||
Reference in New Issue
Block a user