fix: several divergences of code from spec tackled
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
37
locales/render/de.json
Normal 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
37
locales/render/en.json
Normal 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
37
locales/render/es.json
Normal 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
37
locales/render/fr.json
Normal 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
37
locales/render/it.json
Normal 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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user