fix: several divergences of code from spec tackled

This commit is contained in:
2026-04-05 08:35:43 +02:00
parent f72d87eafe
commit 75cb19a604
15 changed files with 422 additions and 51 deletions

View File

@@ -3,7 +3,7 @@
This is the Rust rewrite of an existing project bDS written in Typescript and living in ../bDS - if
in doubt about behaviour, look at the original code to verify.
This project has an allium spec in the folder spec/ - use it to verify behaviour against expected behaviour. It is based on the typescript implementation.
This project has an allium spec in the folder spec/ - use it to verify behaviour against expected behaviour. It is based on the typescript implementation. The command line utility is installed.
Invariants and behaviours in the allium spec should be covered by unit tests of the application code, to make sure the spec is followed.
@@ -43,4 +43,5 @@ Invariants and behaviours in the allium spec should be covered by unit tests of
- do not embedd CSS/JavaScript into HTML, always reference .css and .js files in the project assets
- always make sure you follow proper i18n best practices. no untranslated string constants.
- when creating rust source code, always follow what the allium spec is saying for that part
- when tending the allium spec, make sure you validate the spec with the installed command line utility

View File

@@ -11,6 +11,9 @@ use crate::model::Project;
use crate::model::metadata::ProjectMetadata;
use crate::util::{atomic_write_str, now_unix_ms, slugify, ensure_unique};
/// The well-known ID of the default project (spec: DefaultProjectExists).
pub const DEFAULT_PROJECT_ID: &str = "default";
/// Create a new project: insert into DB, create directory structure, write default meta files.
pub fn create_project(
conn: &Connection,
@@ -51,6 +54,41 @@ pub fn create_project(
Ok(project)
}
/// Ensure the default project (id="default") exists.
/// Creates it on first launch if missing, per the DefaultProjectExists invariant.
/// Returns the project (existing or newly created).
pub fn ensure_default_project(
conn: &Connection,
default_data_dir: Option<&Path>,
) -> EngineResult<Project> {
match q::get_project_by_id(conn, DEFAULT_PROJECT_ID) {
Ok(p) => Ok(p),
Err(rusqlite::Error::QueryReturnedNoRows) => {
let now = now_unix_ms();
let project = Project {
id: DEFAULT_PROJECT_ID.to_string(),
name: "My Blog".to_string(),
slug: "my-blog".to_string(),
description: None,
data_path: default_data_dir.map(|p| p.to_string_lossy().to_string()),
is_active: true,
created_at: now,
updated_at: now,
};
q::insert_project(conn, &project)?;
let data_dir = match default_data_dir {
Some(p) => p.to_path_buf(),
None => std::path::PathBuf::from("projects").join(DEFAULT_PROJECT_ID),
};
create_directory_structure(&data_dir)?;
write_default_meta_files(&data_dir, "My Blog")?;
Ok(project)
}
Err(e) => Err(EngineError::Db(e)),
}
}
/// Get the currently active project, if any.
pub fn get_active_project(conn: &Connection) -> EngineResult<Option<Project>> {
match q::get_active_project(conn) {
@@ -72,9 +110,16 @@ pub fn list_projects(conn: &Connection) -> EngineResult<Vec<Project>> {
}
/// Delete a project row (cascading handled by queries).
/// Rejects deletion of the currently active project.
/// Rejects deletion of the default project and the currently active project.
/// Optionally cleans up the project data directory.
pub fn delete_project(conn: &Connection, project_id: &str, data_dir: Option<&Path>) -> EngineResult<()> {
// Cannot delete the default project
if project_id == DEFAULT_PROJECT_ID {
return Err(EngineError::Validation(
"cannot delete the default project".to_string(),
));
}
// Check if this is the active project (don't delete active)
if let Ok(active) = q::get_active_project(conn) {
if active.id == project_id {
@@ -262,4 +307,34 @@ mod tests {
let result = delete_project(db.conn(), &p.id, None);
assert!(result.is_err());
}
#[test]
fn ensure_default_project_creates_on_first_call() {
let (db, dir) = setup();
let data_path = dir.path().join("default-data");
let p = ensure_default_project(db.conn(), Some(&data_path)).unwrap();
assert_eq!(p.id, DEFAULT_PROJECT_ID);
assert_eq!(p.name, "My Blog");
assert!(p.is_active);
assert!(data_path.join("posts").is_dir());
assert!(data_path.join("meta/project.json").exists());
}
#[test]
fn ensure_default_project_idempotent() {
let (db, dir) = setup();
let data_path = dir.path().join("default-data");
let p1 = ensure_default_project(db.conn(), Some(&data_path)).unwrap();
let p2 = ensure_default_project(db.conn(), Some(&data_path)).unwrap();
assert_eq!(p1.id, p2.id);
}
#[test]
fn delete_default_project_rejected() {
let (db, dir) = setup();
let data_path = dir.path().join("default-data");
ensure_default_project(db.conn(), Some(&data_path)).unwrap();
let result = delete_project(db.conn(), DEFAULT_PROJECT_ID, None);
assert!(result.is_err());
}
}

View File

@@ -39,6 +39,8 @@ pub enum TranslationIssueKind {
SameLanguageAsCanonical,
DoNotTranslateHasTranslations,
ContentInDatabase,
/// Published post is missing a translation for a configured blog language.
MissingTranslation,
}
/// Result of translation validation.
@@ -58,8 +60,10 @@ pub fn validate_translations(
conn: &Connection,
data_dir: &Path,
project_id: &str,
blog_languages: &[String],
main_language: &str,
) -> EngineResult<TranslationValidationReport> {
validate_translations_with_progress(conn, data_dir, project_id, None)
validate_translations_with_progress(conn, data_dir, project_id, blog_languages, main_language, None)
}
/// Like `validate_translations` but with optional per-item progress.
@@ -67,6 +71,8 @@ pub fn validate_translations_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
blog_languages: &[String],
main_language: &str,
on_item: Option<ItemProgressFn>,
) -> EngineResult<TranslationValidationReport> {
let posts = post_q::list_posts_by_project(conn, project_id)?;
@@ -122,6 +128,33 @@ pub fn validate_translations_with_progress(
});
}
}
// Check: published, translatable posts must have translations
// for each configured blog language (spec: ValidateTranslations rule)
if post.status == PostStatus::Published && !post.do_not_translate {
let available: std::collections::HashSet<String> = translations
.iter()
.map(|t| norm_lang(&t.language))
.collect();
let post_lang = norm_lang(post.language.as_deref().unwrap_or(main_language));
let main_norm = norm_lang(main_language);
for lang in blog_languages {
let lang_norm = norm_lang(lang);
if lang_norm == main_norm || lang_norm == post_lang {
continue;
}
if !available.contains(&lang_norm) {
db_issues.push(TranslationIssue {
post_id: post.id.clone(),
translation_id: None,
file_path: None,
language: lang.clone(),
kind: TranslationIssueKind::MissingTranslation,
});
}
}
}
}
// Phase 2: filesystem validation

View File

@@ -91,6 +91,30 @@ static CATALOG_IT: LazyLock<Catalog> =
static CATALOG_ES: LazyLock<Catalog> =
LazyLock::new(|| parse_catalog(include_str!("../../../../locales/ui/es.json")));
// ── Render catalogs (content/template locale, independent of UI locale) ──
static RENDER_EN: LazyLock<Catalog> =
LazyLock::new(|| parse_catalog(include_str!("../../../../locales/render/en.json")));
static RENDER_DE: LazyLock<Catalog> =
LazyLock::new(|| parse_catalog(include_str!("../../../../locales/render/de.json")));
static RENDER_FR: LazyLock<Catalog> =
LazyLock::new(|| parse_catalog(include_str!("../../../../locales/render/fr.json")));
static RENDER_IT: LazyLock<Catalog> =
LazyLock::new(|| parse_catalog(include_str!("../../../../locales/render/it.json")));
static RENDER_ES: LazyLock<Catalog> =
LazyLock::new(|| parse_catalog(include_str!("../../../../locales/render/es.json")));
fn render_catalog_for(code: &str) -> &'static Catalog {
let base = code.split(['-', '_']).next().unwrap_or("en").to_lowercase();
match base.as_str() {
"de" => &RENDER_DE,
"fr" => &RENDER_FR,
"it" => &RENDER_IT,
"es" => &RENDER_ES,
_ => &RENDER_EN,
}
}
fn catalog_for(locale: UiLocale) -> &'static Catalog {
match locale {
UiLocale::En => &CATALOG_EN,
@@ -129,6 +153,31 @@ pub fn translate_with(locale: UiLocale, key: &str, params: &[(&str, &str)]) -> S
result
}
/// Look up a render/template translation key by content language code.
///
/// This is independent of the UI locale — it uses the project's content language.
/// Fallback chain: requested language → English → key itself.
/// Implements the RenderTranslations invariant from i18n.allium.
pub fn translate_render(language: &str, key: &str) -> String {
let catalog = render_catalog_for(language);
if let Some(val) = catalog.get(key) {
return val.clone();
}
if !language.starts_with("en") {
if let Some(val) = RENDER_EN.get(key) {
return val.clone();
}
}
key.to_string()
}
/// Return the entire render translation map for a language.
///
/// Used to inject as `translations` into the Liquid template context.
pub fn get_render_translations(language: &str) -> &'static HashMap<String, String> {
render_catalog_for(language)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -232,4 +281,43 @@ mod tests {
fn detect_os_locale_does_not_panic() {
let _ = detect_os_locale();
}
// RenderTranslations invariant: separate catalog for content/template locale
#[test]
fn translate_render_english() {
assert_eq!(translate_render("en", "render.archive"), "Archive");
assert_eq!(translate_render("en", "render.month.1"), "January");
}
#[test]
fn translate_render_german() {
assert_eq!(translate_render("de", "render.archive"), "Archiv");
assert_eq!(translate_render("de", "render.month.1"), "Januar");
}
#[test]
fn translate_render_falls_back_to_english() {
assert_eq!(translate_render("ja", "render.archive"), "Archive");
}
#[test]
fn translate_render_missing_key_returns_key() {
assert_eq!(translate_render("en", "render.nonexistent"), "render.nonexistent");
}
#[test]
fn get_render_translations_not_empty() {
let map = get_render_translations("en");
assert!(map.contains_key("render.archive"));
assert!(map.contains_key("render.month.12"));
assert!(map.len() >= 34);
}
#[test]
fn all_render_locales_have_archive_key() {
for code in &["en", "de", "fr", "it", "es"] {
let val = translate_render(code, "render.archive");
assert_ne!(val, "render.archive", "missing render.archive for {code}");
}
}
}

View File

@@ -29,11 +29,12 @@ pub enum ThumbnailFit {
Contain,
}
/// Standard thumbnail sizes matching TypeScript implementation.
/// Standard thumbnail sizes matching spec: small/medium/large are
/// width-constrained aspect-preserving; AI is letterboxed on black.
pub const THUMBNAIL_SIZES: &[ThumbnailSize] = &[
ThumbnailSize { name: "small", width: 150, height: 150, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Cover },
ThumbnailSize { name: "medium", width: 400, height: 400, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Cover },
ThumbnailSize { name: "large", width: 800, height: 800, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Cover },
ThumbnailSize { name: "small", width: 150, height: 150, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "medium", width: 400, height: 400, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "large", width: 800, height: 800, format: ThumbnailFormat::Webp, fit: ThumbnailFit::Inside },
ThumbnailSize { name: "ai", width: 448, height: 448, format: ThumbnailFormat::Jpeg, fit: ThumbnailFit::Contain },
];
@@ -109,17 +110,6 @@ pub fn generate_all_thumbnails(
let mut paths = Vec::new();
let prefix = &media_id[..2.min(media_id.len())];
// Save thumbnail source for regeneration
let source_ext = source.extension().and_then(|e| e.to_str()).unwrap_or("bin");
let source_dest = thumbnails_dir
.join(prefix)
.join(format!("{media_id}_source.{source_ext}"));
if let Some(parent) = source_dest.parent() {
fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
}
fs::copy(source, &source_dest).map_err(|e| format!("save thumbnail source: {e}"))?;
paths.push(source_dest.to_string_lossy().to_string());
for size in THUMBNAIL_SIZES {
let ext = match size.format {
ThumbnailFormat::Webp => "webp",
@@ -286,13 +276,13 @@ mod tests {
let dir = TempDir::new().unwrap();
let source = create_test_png(dir.path());
let dest = dir.path().join("thumb.webp");
let size = &THUMBNAIL_SIZES[0]; // small: 150x150 Cover
let size = &THUMBNAIL_SIZES[0]; // small: 150 Inside
generate_thumbnail(&source, &dest, size, 80).unwrap();
assert!(dest.exists());
let (w, h) = image_dimensions(&dest).unwrap();
// 100x80 resized to fill 150x150 (Cover fit)
assert_eq!(w, 150);
assert_eq!(h, 150);
// 100x80 fits inside 150x150 without enlargement
assert_eq!(w, 100);
assert_eq!(h, 80);
}
#[test]
@@ -314,7 +304,7 @@ mod tests {
let source = create_test_png(dir.path());
let thumb_dir = dir.path().join("thumbnails");
let paths = generate_all_thumbnails(&source, &thumb_dir, "ab123456-test-uuid").unwrap();
assert_eq!(paths.len(), 5); // 1 source + 4 thumbnails
assert_eq!(paths.len(), 4); // 4 thumbnails (no source copy)
for p in &paths {
assert!(Path::new(p).exists(), "thumbnail missing: {p}");
}

View File

@@ -550,6 +550,10 @@ impl BdsApp {
"engine.validateTranslationsStarted",
|db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
let meta = engine::meta::read_project_json(&data_dir)
.map_err(|e| e.to_string())?;
let main_lang = meta.main_language.as_deref().unwrap_or("en");
let blog_langs = meta.blog_languages.clone();
let tm2 = Arc::clone(&tm);
let on_item: engine::validate_translations::ItemProgressFn = Box::new(move |current, total, name| {
let pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
@@ -557,7 +561,7 @@ impl BdsApp {
tm2.report_progress(tid, Some(pct), Some(msg));
});
let report = engine::validate_translations::validate_translations_with_progress(
db.conn(), &data_dir, &project_id, Some(on_item),
db.conn(), &data_dir, &project_id, &blog_langs, main_lang, Some(on_item),
).map_err(|e| e.to_string())?;
Ok(format!("db_issues={}, fs_issues={}", report.db_issues.len(), report.fs_issues.len()))
},

37
locales/render/de.json Normal file
View File

@@ -0,0 +1,37 @@
{
"render.archive": "Archiv",
"render.pagination.label": "Seitennummerierung",
"render.pagination.newer": "neuer",
"render.pagination.older": "\u00e4lter",
"render.notFound.message": "Die angeforderte Vorschauseite konnte nicht gefunden werden.",
"render.notFound.back": "Zur\u00fcck zur Vorschau-Startseite",
"render.photoArchive.empty": "Keine Fotos f\u00fcr dieses Archiv gefunden.",
"render.gallery.empty": "Keine verkn\u00fcpften Bilder gefunden.",
"render.tagCloud.empty": "Keine Tags gefunden.",
"render.tagCloud.ariaLabel": "Tag-Wolke",
"render.calendar.open": "Kalender \u00f6ffnen",
"render.calendar.close": "Kalender schlie\u00dfen",
"render.calendar.title": "Archivkalender",
"render.calendar.loading": "Kalender wird geladen \u2026",
"render.calendar.error": "Kalenderdaten konnten nicht geladen werden.",
"render.taxonomy.ariaLabel": "Taxonomie",
"render.backlinks.label": "Verlinkt von",
"render.backlinks.ariaLabel": "R\u00fcckverweise",
"render.languageSwitcher.ariaLabel": "Sprache",
"render.video.youtubeTitle": "YouTube-Video",
"render.video.vimeoTitle": "Vimeo-Video",
"render.month.1": "Januar",
"render.month.2": "Februar",
"render.month.3": "M\u00e4rz",
"render.month.4": "Apr.",
"render.month.5": "Mai",
"render.month.6": "Juni",
"render.month.7": "Juli",
"render.month.8": "Aug.",
"render.month.9": "Sept.",
"render.month.10": "Oktober",
"render.month.11": "Nov.",
"render.month.12": "Dezember",
"render.search.placeholder": "Suchen...",
"render.search.ariaLabel": "Seitensuche"
}

37
locales/render/en.json Normal file
View File

@@ -0,0 +1,37 @@
{
"render.archive": "Archive",
"render.pagination.label": "Pagination",
"render.pagination.newer": "newer",
"render.pagination.older": "older",
"render.notFound.message": "The requested preview page could not be found.",
"render.notFound.back": "Back to preview home",
"render.photoArchive.empty": "No photos found for this archive.",
"render.gallery.empty": "No linked images found.",
"render.tagCloud.empty": "No tags found.",
"render.tagCloud.ariaLabel": "Tag cloud",
"render.calendar.open": "Open calendar",
"render.calendar.close": "Close calendar",
"render.calendar.title": "Archive calendar",
"render.calendar.loading": "Loading calendar\u2026",
"render.calendar.error": "Calendar data could not be loaded.",
"render.taxonomy.ariaLabel": "Taxonomy",
"render.backlinks.label": "Linked from",
"render.backlinks.ariaLabel": "Backlinks",
"render.languageSwitcher.ariaLabel": "Language",
"render.video.youtubeTitle": "YouTube video",
"render.video.vimeoTitle": "Vimeo video",
"render.month.1": "January",
"render.month.2": "February",
"render.month.3": "March",
"render.month.4": "April",
"render.month.5": "May",
"render.month.6": "June",
"render.month.7": "July",
"render.month.8": "August",
"render.month.9": "September",
"render.month.10": "October",
"render.month.11": "November",
"render.month.12": "December",
"render.search.placeholder": "Search...",
"render.search.ariaLabel": "Site search"
}

37
locales/render/es.json Normal file
View File

@@ -0,0 +1,37 @@
{
"render.archive": "Archivo",
"render.pagination.label": "Paginaci\u00f3n",
"render.pagination.newer": "m\u00e1s reciente",
"render.pagination.older": "m\u00e1s antiguo",
"render.notFound.message": "No se pudo encontrar la p\u00e1gina de vista previa solicitada.",
"render.notFound.back": "Volver al inicio de vista previa",
"render.photoArchive.empty": "No se encontraron fotos para este archivo.",
"render.gallery.empty": "No se encontraron im\u00e1genes vinculadas.",
"render.tagCloud.empty": "No se encontraron etiquetas.",
"render.tagCloud.ariaLabel": "Nube de etiquetas",
"render.calendar.open": "Abrir calendario",
"render.calendar.close": "Cerrar calendario",
"render.calendar.title": "Calendario de archivo",
"render.calendar.loading": "Cargando calendario\u2026",
"render.calendar.error": "No se pudieron cargar los datos del calendario.",
"render.taxonomy.ariaLabel": "Taxonom\u00eda",
"render.backlinks.label": "Enlazado desde",
"render.backlinks.ariaLabel": "Retroenlaces",
"render.languageSwitcher.ariaLabel": "Idioma",
"render.video.youtubeTitle": "V\u00eddeo de YouTube",
"render.video.vimeoTitle": "V\u00eddeo de Vimeo",
"render.month.1": "enero",
"render.month.2": "febrero",
"render.month.3": "marzo",
"render.month.4": "abril",
"render.month.5": "mayo",
"render.month.6": "junio",
"render.month.7": "julio",
"render.month.8": "agosto",
"render.month.9": "septiembre",
"render.month.10": "octubre",
"render.month.11": "noviembre",
"render.month.12": "diciembre",
"render.search.placeholder": "Buscar...",
"render.search.ariaLabel": "Buscar en el sitio"
}

37
locales/render/fr.json Normal file
View File

@@ -0,0 +1,37 @@
{
"render.archive": "Archives",
"render.pagination.label": "Navigation pagin\u00e9e",
"render.pagination.newer": "plus r\u00e9cent",
"render.pagination.older": "plus ancien",
"render.notFound.message": "La page d\u2019aper\u00e7u demand\u00e9e est introuvable.",
"render.notFound.back": "Retour \u00e0 l\u2019accueil de l\u2019aper\u00e7u",
"render.photoArchive.empty": "Aucune photo trouv\u00e9e pour cette archive.",
"render.gallery.empty": "Aucune image li\u00e9e trouv\u00e9e.",
"render.tagCloud.empty": "Aucun tag trouv\u00e9.",
"render.tagCloud.ariaLabel": "Nuage de tags",
"render.calendar.open": "Ouvrir le calendrier",
"render.calendar.close": "Fermer le calendrier",
"render.calendar.title": "Calendrier des archives",
"render.calendar.loading": "Chargement du calendrier\u2026",
"render.calendar.error": "Impossible de charger les donn\u00e9es du calendrier.",
"render.taxonomy.ariaLabel": "Taxonomie",
"render.backlinks.label": "Li\u00e9 depuis",
"render.backlinks.ariaLabel": "R\u00e9troliens",
"render.languageSwitcher.ariaLabel": "Langue",
"render.video.youtubeTitle": "Vid\u00e9o YouTube",
"render.video.vimeoTitle": "Vid\u00e9o Vimeo",
"render.month.1": "janvier",
"render.month.2": "f\u00e9vrier",
"render.month.3": "mars",
"render.month.4": "avril",
"render.month.5": "mai",
"render.month.6": "juin",
"render.month.7": "juillet",
"render.month.8": "ao\u00fbt",
"render.month.9": "septembre",
"render.month.10": "octobre",
"render.month.11": "novembre",
"render.month.12": "d\u00e9cembre",
"render.search.placeholder": "Rechercher...",
"render.search.ariaLabel": "Recherche du site"
}

37
locales/render/it.json Normal file
View File

@@ -0,0 +1,37 @@
{
"render.archive": "Archivio",
"render.pagination.label": "Paginazione",
"render.pagination.newer": "pi\u00f9 recente",
"render.pagination.older": "pi\u00f9 vecchio",
"render.notFound.message": "La pagina di anteprima richiesta non \u00e8 stata trovata.",
"render.notFound.back": "Torna alla home di anteprima",
"render.photoArchive.empty": "Nessuna foto trovata per questo archivio.",
"render.gallery.empty": "Nessuna immagine collegata trovata.",
"render.tagCloud.empty": "Nessun tag trovato.",
"render.tagCloud.ariaLabel": "Nuvola di tag",
"render.calendar.open": "Apri calendario",
"render.calendar.close": "Chiudi calendario",
"render.calendar.title": "Calendario archivio",
"render.calendar.loading": "Caricamento calendario\u2026",
"render.calendar.error": "Impossibile caricare i dati del calendario.",
"render.taxonomy.ariaLabel": "Tassonomia",
"render.backlinks.label": "Collegato da",
"render.backlinks.ariaLabel": "Retrocollegamenti",
"render.languageSwitcher.ariaLabel": "Lingua",
"render.video.youtubeTitle": "Video YouTube",
"render.video.vimeoTitle": "Video Vimeo",
"render.month.1": "gennaio",
"render.month.2": "febbraio",
"render.month.3": "marzo",
"render.month.4": "aprile",
"render.month.5": "maggio",
"render.month.6": "giugno",
"render.month.7": "luglio",
"render.month.8": "agosto",
"render.month.9": "settembre",
"render.month.10": "ottobre",
"render.month.11": "novembre",
"render.month.12": "dicembre",
"render.search.placeholder": "Cerca...",
"render.search.ariaLabel": "Ricerca nel sito"
}

View File

@@ -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)

View File

@@ -41,7 +41,7 @@ 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]
let prefix = substring(m.id, 0, 2)
m.thumbnails.small = format("thumbnails/{prefix}/{id}-small.webp",
prefix: prefix, id: m.id)
m.thumbnails.medium = format("thumbnails/{prefix}/{id}-medium.webp",
@@ -62,8 +62,7 @@ config {
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_format: String = "webp" -- All sizes except AI (encoder default quality)
thumbnail_ai_format: String = "jpeg" -- AI thumbnail only
}
@@ -75,21 +74,18 @@ rule GenerateThumbnails {
source: media.file_path,
destination: media.thumbnails.small,
width: config.thumbnail_small_width,
quality: config.thumbnail_quality,
format: config.thumbnail_format
)
ensures: ThumbnailGenerated(
source: media.file_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(
@@ -103,10 +99,10 @@ rule GenerateThumbnails {
-- Thumbnail generation algorithm
value ThumbnailGeneration {
-- 1. Load source image
-- 2. Read raw image dimensions from header (not EXIF-orientation-aware)
-- 2. Apply EXIF orientation correction (rotation, flip) so thumbnails display correctly
-- 3. Resize: small/medium/large preserve aspect ratio (width-constrained)
-- AI thumbnail is a 448x448 center crop
-- 4. Encode as WebP (quality 80) for small/medium/large
-- AI thumbnail is a 448x448 center crop (letterboxed on black background)
-- 4. Encode as WebP (encoder default quality) for small/medium/large
-- Encode as JPEG for AI thumbnail
-- 5. Write to bucketed thumbnail path: thumbnails/{id[0:2]}/{id}-{size}.{ext}
--
@@ -114,12 +110,9 @@ value ThumbnailGeneration {
}
invariant ThumbnailExifHandling {
-- 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
-- EXIF orientation IS applied during thumbnail generation so that
-- thumbnails always appear right-side-up regardless of camera metadata.
-- Width/height stored in DB are the raw header values (pre-rotation).
}
-- ============================================================================
@@ -141,7 +134,7 @@ value ImageProcessing {
}
-- Processing rules:
-- 1. All thumbnails (except AI) are encoded as WebP quality 80
-- 1. All thumbnails (except AI) are encoded as WebP (encoder default quality)
-- 2. AI thumbnail is encoded as JPEG (for vision model compatibility)
-- 3. Original format is preserved for full-size assets (no conversion)
-- 4. EXIF data is not stripped (thumbnails are re-encoded, so EXIF is naturally absent)

View File

@@ -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: ""

View File

@@ -381,21 +381,23 @@ invariant UniqueDismissedDuplicatePair {
value Fts5PostSchema {
-- CREATE VIRTUAL TABLE posts_fts USING fts5(
-- title, excerpt, content, tags, categories,
-- content='posts',
-- content_rowid='rowid'
-- post_id UNINDEXED,
-- title, excerpt, content, tags, categories
-- );
fields: Set<String> -- {title, excerpt, content, tags, categories}
-- Standalone table (no content-sync) because text is pre-stemmed
-- via Snowball before insertion; content-sync would read un-stemmed
-- base-table text at query time instead.
fields: Set<String> -- {post_id UNINDEXED, title, excerpt, content, tags, categories}
stemmer_languages: Integer = 24
}
value Fts5MediaSchema {
-- CREATE VIRTUAL TABLE media_fts USING fts5(
-- title, alt, caption, original_name, tags,
-- content='media',
-- content_rowid='rowid'
-- media_id UNINDEXED,
-- title, alt, caption, original_name, tags
-- );
fields: Set<String> -- {title, alt, caption, original_name, tags}
-- Standalone table (no content-sync) — same rationale as posts_fts.
fields: Set<String> -- {media_id UNINDEXED, title, alt, caption, original_name, tags}
stemmer_languages: Integer = 24
}