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

@@ -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}");
}