fix: more finishing work for M3

This commit is contained in:
2026-04-08 16:57:16 +02:00
parent 8022013fcf
commit be7bfa97f6
16 changed files with 3113 additions and 550 deletions

View File

@@ -11,10 +11,12 @@ use crate::db::queries::media_translation as qmt;
use crate::db::queries::post_media as qpm;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Media, MediaTranslation};
use crate::util::sidecar::{MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar};
use crate::util::sidecar::{
read_sidecar, read_translation_sidecar, MediaSidecar, MediaTranslationSidecar,
};
use crate::util::thumbnail::{
generate_all_thumbnails, image_dimensions, mime_from_extension,
ThumbnailFormat, THUMBNAIL_SIZES,
generate_all_thumbnails, image_dimensions, mime_from_extension, ThumbnailFormat,
THUMBNAIL_SIZES,
};
use crate::util::{
atomic_write_str, content_hash, media_dir_path, media_sidecar_path,
@@ -33,9 +35,14 @@ pub struct MediaRebuildReport {
/// Supported image MIME types for import (per media_processing.allium).
const SUPPORTED_IMAGE_TYPES: &[&str] = &[
"image/jpeg", "image/png", "image/gif",
"image/webp", "image/tiff", "image/bmp",
"image/heic", "image/heif",
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/tiff",
"image/bmp",
"image/heic",
"image/heif",
];
/// Import a media file (image, etc.) into the project.
@@ -184,11 +191,7 @@ pub fn update_media(
}
/// Delete a media item and all related artifacts.
pub fn delete_media(
conn: &Connection,
data_dir: &Path,
media_id: &str,
) -> EngineResult<()> {
pub fn delete_media(conn: &Connection, data_dir: &Path, media_id: &str) -> EngineResult<()> {
let media = qm::get_media_by_id(conn, media_id)?;
// Delete binary file
@@ -363,10 +366,7 @@ pub fn rebuild_media_from_filesystem_with_progress(
let mut canonical_sidecars = Vec::new();
let mut translation_sidecars = Vec::new();
for entry in WalkDir::new(&media_dir)
.into_iter()
.filter_map(|e| e.ok())
{
for entry in WalkDir::new(&media_dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
@@ -931,7 +931,10 @@ mod tests {
delete_media_translation(db.conn(), dir.path(), &media.id, "de").unwrap();
// Sidecar should be gone
assert!(!abs_sidecar.exists(), "translation sidecar should be removed");
assert!(
!abs_sidecar.exists(),
"translation sidecar should be removed"
);
// DB entry should be gone
assert!(
@@ -964,7 +967,11 @@ createdAt: 2024-01-15T12:00:00.000Z
updatedAt: 2024-01-15T12:00:00.000Z
tags: [\"test\"]
---";
fs::write(media_subdir.join("abcdef12-test-uuid.png.meta"), sidecar_content).unwrap();
fs::write(
media_subdir.join("abcdef12-test-uuid.png.meta"),
sidecar_content,
)
.unwrap();
// Write a translation sidecar
let trans_sidecar_content = "\

View File

@@ -182,11 +182,7 @@ pub fn update_post(
}
/// Publish a post: write file, clear content, set published_at.
pub fn publish_post(
conn: &Connection,
data_dir: &Path,
post_id: &str,
) -> EngineResult<Post> {
pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<Post> {
let mut post = qp::get_post_by_id(conn, post_id)?;
// Require Draft or Archived status
@@ -211,8 +207,7 @@ pub fn publish_post(
c.clone()
} else if abs_path.exists() {
let file_content = fs::read_to_string(&abs_path)?;
let (_fm, body) = read_post_file(&file_content)
.map_err(|e| EngineError::Parse(e))?;
let (_fm, body) = read_post_file(&file_content).map_err(|e| EngineError::Parse(e))?;
body
} else {
String::new()
@@ -235,8 +230,7 @@ pub fn publish_post(
// Set published snapshot fields
let tags_json = serde_json::to_string(&post.tags).unwrap_or_else(|_| "[]".into());
let cats_json =
serde_json::to_string(&post.categories).unwrap_or_else(|_| "[]".into());
let cats_json = serde_json::to_string(&post.categories).unwrap_or_else(|_| "[]".into());
post.published_title = Some(post.title.clone());
post.published_content = Some(body.clone());
post.published_tags = Some(tags_json.clone());
@@ -313,7 +307,8 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
));
}
// Reload content from filesystem if transitioning from published (content is NULL)
if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty() {
if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty()
{
let abs_path = data_dir.join(&post.file_path);
if abs_path.exists() {
if let Ok(file_content) = fs::read_to_string(&abs_path) {
@@ -329,12 +324,156 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine
Ok(())
}
/// Delete a post and all related data.
pub fn delete_post(
/// Discard unpublished draft changes and restore the published version.
pub fn discard_post_draft(
conn: &Connection,
data_dir: &Path,
post_id: &str,
) -> EngineResult<()> {
) -> EngineResult<Post> {
let mut post = qp::get_post_by_id(conn, post_id)?;
if post.published_at.is_none() {
return Err(EngineError::Conflict(
"cannot discard changes for a post that was never published".to_string(),
));
}
let (title, slug, excerpt, author, language, template_slug, do_not_translate, tags, categories, checksum) =
if !post.file_path.is_empty() {
let abs_path = data_dir.join(&post.file_path);
if abs_path.exists() {
let raw = fs::read_to_string(&abs_path)?;
let (fm, _body) = read_post_file(&raw).map_err(EngineError::Parse)?;
(
fm.title,
fm.slug,
fm.excerpt,
fm.author,
fm.language,
fm.template_slug,
fm.do_not_translate,
fm.tags,
fm.categories,
Some(content_hash(raw.as_bytes())),
)
} else {
(
post.published_title.clone().unwrap_or_else(|| post.title.clone()),
post.slug.clone(),
post.published_excerpt.clone().or_else(|| post.excerpt.clone()),
post.author.clone(),
post.language.clone(),
post.template_slug.clone(),
post.do_not_translate,
post.published_tags
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.tags.clone()),
post.published_categories
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.categories.clone()),
post.checksum.clone(),
)
}
} else {
(
post.published_title.clone().unwrap_or_else(|| post.title.clone()),
post.slug.clone(),
post.published_excerpt.clone().or_else(|| post.excerpt.clone()),
post.author.clone(),
post.language.clone(),
post.template_slug.clone(),
post.do_not_translate,
post.published_tags
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.tags.clone()),
post.published_categories
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_else(|| post.categories.clone()),
post.checksum.clone(),
)
};
post.title = title;
post.slug = slug;
post.excerpt = excerpt;
post.author = author;
post.language = language;
post.template_slug = template_slug;
post.do_not_translate = do_not_translate;
post.tags = tags;
post.categories = categories;
post.content = None;
post.status = PostStatus::Published;
post.checksum = checksum;
post.updated_at = now_unix_ms();
qp::update_post(conn, &post)?;
fts_index_post(conn, &post)?;
Ok(post)
}
/// Create a new draft post by duplicating an existing post.
pub fn duplicate_post(
conn: &Connection,
data_dir: &Path,
post_id: &str,
) -> EngineResult<Post> {
let source = qp::get_post_by_id(conn, post_id)?;
let body = if let Some(ref content) = source.content {
content.clone()
} else if !source.file_path.is_empty() {
let abs_path = data_dir.join(&source.file_path);
if abs_path.exists() {
let raw = fs::read_to_string(&abs_path)?;
let (_fm, body) = read_post_file(&raw).map_err(EngineError::Parse)?;
body
} else {
String::new()
}
} else {
String::new()
};
let duplicate_title = if source.title.is_empty() {
"Untitled Copy".to_string()
} else {
format!("{} Copy", source.title)
};
let duplicated = create_post(
conn,
data_dir,
&source.project_id,
&duplicate_title,
Some(&body),
source.tags.clone(),
source.categories.clone(),
source.author.as_deref(),
source.language.as_deref(),
source.template_slug.as_deref(),
)?;
update_post(
conn,
data_dir,
&duplicated.id,
None,
None,
Some(source.excerpt.as_deref()),
None,
None,
None,
None,
None,
None,
Some(source.do_not_translate),
)
}
/// Delete a post and all related data.
pub fn delete_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<()> {
let post = qp::get_post_by_id(conn, post_id)?;
// Delete .md file if exists
@@ -507,10 +646,7 @@ pub fn rebuild_posts_from_filesystem_with_progress(
let mut canonical_files = Vec::new();
let mut translation_files = Vec::new();
for entry in WalkDir::new(&posts_dir)
.into_iter()
.filter_map(|e| e.ok())
{
for entry in WalkDir::new(&posts_dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
@@ -659,10 +795,14 @@ fn parse_post_links(content: &str) -> Vec<(String, String)> {
let url = &line[url_start..url_start + paren_end];
// Check if URL matches /YYYY/MM/DD/slug pattern
let parts: Vec<&str> = url.trim_end_matches('/').split('/').collect();
if parts.len() == 5 && parts[0].is_empty()
&& parts[1].len() == 4 && parts[1].chars().all(|c| c.is_ascii_digit())
&& parts[2].len() == 2 && parts[2].chars().all(|c| c.is_ascii_digit())
&& parts[3].len() == 2 && parts[3].chars().all(|c| c.is_ascii_digit())
if parts.len() == 5
&& parts[0].is_empty()
&& parts[1].len() == 4
&& parts[1].chars().all(|c| c.is_ascii_digit())
&& parts[2].len() == 2
&& parts[2].chars().all(|c| c.is_ascii_digit())
&& parts[3].len() == 2
&& parts[3].chars().all(|c| c.is_ascii_digit())
{
links.push((parts[4].to_string(), link_text.to_string()));
}
@@ -862,8 +1002,7 @@ fn rebuild_translation(
path: &Path,
) -> EngineResult<bool> {
let content = fs::read_to_string(path)?;
let (fm, body) =
read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
let (fm, body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
let rel_path = path
.strip_prefix(data_dir)
@@ -940,6 +1079,44 @@ fn rebuild_translation(
}
}
// ───────────────────────────────────────────────────────────
// M3: Editor Actions
// ───────────────────────────────────────────────────────────
/// Insert a link to another post in the editor buffer.
/// Returns the Markdown link syntax.
pub fn post_insert_link(slug: &str) -> String {
format!("[title](/YYYY/MM/DD/{slug})")
}
/// Insert a media reference in the editor buffer.
/// Returns the Markdown syntax: ![alt](bds-media://id) or [name](bds-media://id)
pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) -> String {
if is_image {
format!(
"![]({bds_media_url})",
bds_media_url = format!("bds-media://{media_id}")
)
} else {
format!(
"[{original_name}]({bds_media_url})",
bds_media_url = format!("bds-media://{media_id}")
)
}
}
/// Get posts linked from a given post (outlinks).
pub fn list_post_outlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec<String>> {
let links = ql::list_links_by_source(conn, post_id)?;
Ok(links.into_iter().map(|l| l.target_post_id).collect())
}
/// Get posts linking to a given post (backlinks).
pub fn list_post_backlinks(conn: &Connection, post_id: &str) -> EngineResult<Vec<String>> {
let links = ql::list_links_by_target(conn, post_id)?;
Ok(links.into_iter().map(|l| l.source_post_id).collect())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1181,6 +1358,103 @@ mod tests {
assert!(file_content.contains("Publish Me"));
}
#[test]
fn discard_post_draft_restores_published_state() {
let (db, dir) = setup();
let post = create_post(
db.conn(),
dir.path(),
"p1",
"Discard Me",
Some("published body"),
vec!["one".into()],
vec!["cat".into()],
Some("Alice"),
Some("en"),
None,
)
.unwrap();
let published = publish_post(db.conn(), dir.path(), &post.id).unwrap();
let updated = update_post(
db.conn(),
dir.path(),
&post.id,
Some("Changed Title"),
None,
Some(Some("changed excerpt")),
Some("draft body"),
Some(vec!["two".into()]),
Some(vec!["other".into()]),
Some(Some("Bob")),
Some(Some("de")),
None,
Some(true),
)
.unwrap();
assert_eq!(updated.status, PostStatus::Draft);
assert_eq!(updated.content.as_deref(), Some("draft body"));
let discarded = discard_post_draft(db.conn(), dir.path(), &post.id).unwrap();
assert_eq!(discarded.status, PostStatus::Published);
assert_eq!(discarded.title, published.title);
assert_eq!(discarded.excerpt, published.published_excerpt);
assert_eq!(discarded.tags, vec!["one"]);
assert_eq!(discarded.categories, vec!["cat"]);
assert_eq!(discarded.content, None);
assert_eq!(discarded.language.as_deref(), Some("en"));
assert!(!discarded.do_not_translate);
}
#[test]
fn duplicate_post_creates_new_draft_copy() {
let (db, dir) = setup();
let post = create_post(
db.conn(),
dir.path(),
"p1",
"Original",
Some("body text"),
vec!["rust".into()],
vec!["guide".into()],
Some("Alice"),
Some("en"),
None,
)
.unwrap();
let source = update_post(
db.conn(),
dir.path(),
&post.id,
None,
None,
Some(Some("excerpt")),
None,
None,
None,
None,
None,
None,
Some(true),
)
.unwrap();
let duplicate = duplicate_post(db.conn(), dir.path(), &post.id).unwrap();
assert_ne!(duplicate.id, source.id);
assert_eq!(duplicate.status, PostStatus::Draft);
assert_eq!(duplicate.title, "Original Copy");
assert_eq!(duplicate.content.as_deref(), Some("body text"));
assert_eq!(duplicate.tags, source.tags);
assert_eq!(duplicate.categories, source.categories);
assert_eq!(duplicate.author, source.author);
assert_eq!(duplicate.language, source.language);
assert_eq!(duplicate.excerpt, source.excerpt);
assert!(duplicate.do_not_translate);
assert!(duplicate.slug.starts_with("original-copy"));
}
#[test]
fn publish_preserves_published_at_on_republish() {
let (db, dir) = setup();
@@ -1388,8 +1662,7 @@ mod tests {
fs::write(posts_dir.join("rebuilt-post.de.md"), trans_content).unwrap();
// Run rebuild
let report =
rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let report = rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.posts_created, 1);
assert_eq!(report.translations_created, 1);
@@ -1402,17 +1675,13 @@ mod tests {
assert_eq!(post.tags, vec!["test"]);
// Verify translation in DB
let trans = qt::get_post_translation_by_post_and_language(
db.conn(),
"rebuild-post-1",
"de",
)
.unwrap();
let trans =
qt::get_post_translation_by_post_and_language(db.conn(), "rebuild-post-1", "de")
.unwrap();
assert_eq!(trans.title, "Wiederhergestellter Beitrag");
// Run rebuild again - should update, not create
let report2 =
rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let report2 = rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report2.posts_created, 0);
assert_eq!(report2.posts_updated, 1);
assert_eq!(report2.translations_created, 0);
@@ -1436,17 +1705,44 @@ mod tests {
fn do_not_translate_guard_rejects_translation() {
let (db, dir) = setup();
let post = create_post(
db.conn(), dir.path(), "p1", "No Translate", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
db.conn(),
dir.path(),
"p1",
"No Translate",
Some("body"),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
// Set do_not_translate
update_post(
db.conn(), dir.path(), &post.id,
None, None, None, None, None, None, None, None, None, Some(true),
).unwrap();
db.conn(),
dir.path(),
&post.id,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(true),
)
.unwrap();
let result = upsert_translation(
db.conn(), dir.path(), &post.id, "de", "German", None, Some("Inhalt"),
db.conn(),
dir.path(),
&post.id,
"de",
"German",
None,
Some("Inhalt"),
);
assert!(result.is_err());
match result.unwrap_err() {
@@ -1459,17 +1755,46 @@ mod tests {
fn update_post_slug_uniqueness_enforced() {
let (db, dir) = setup();
create_post(
db.conn(), dir.path(), "p1", "First", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
db.conn(),
dir.path(),
"p1",
"First",
Some("body"),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
let second = create_post(
db.conn(), dir.path(), "p1", "Second", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
db.conn(),
dir.path(),
"p1",
"Second",
Some("body"),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
let result = update_post(
db.conn(), dir.path(), &second.id,
None, Some("first"), None, None, None, None, None, None, None, None,
db.conn(),
dir.path(),
&second.id,
None,
Some("first"),
None,
None,
None,
None,
None,
None,
None,
None,
);
assert!(result.is_err());
}
@@ -1478,9 +1803,18 @@ mod tests {
fn update_published_post_transitions_to_draft() {
let (db, dir) = setup();
let post = create_post(
db.conn(), dir.path(), "p1", "Published", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
db.conn(),
dir.path(),
"p1",
"Published",
Some("body"),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
publish_post(db.conn(), dir.path(), &post.id).unwrap();
// Archive then update to test auto-draft
@@ -1490,9 +1824,21 @@ mod tests {
// Now update the published post
let updated = update_post(
db.conn(), dir.path(), &post.id,
Some("New Title"), None, None, Some("new body"), None, None, None, None, None, None,
).unwrap();
db.conn(),
dir.path(),
&post.id,
Some("New Title"),
None,
None,
Some("new body"),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(updated.status, PostStatus::Draft);
}
@@ -1507,9 +1853,18 @@ mod tests {
fn publish_post_already_published_rejected() {
let (db, dir) = setup();
let post = create_post(
db.conn(), dir.path(), "p1", "Double Pub", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
db.conn(),
dir.path(),
"p1",
"Double Pub",
Some("body"),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
publish_post(db.conn(), dir.path(), &post.id).unwrap();
let result = publish_post(db.conn(), dir.path(), &post.id);
@@ -1525,18 +1880,36 @@ mod tests {
let (db, dir) = setup();
// Create target post first
let target = create_post(
db.conn(), dir.path(), "p1", "Target Post", Some("target body"),
vec![], vec![], None, None, None,
).unwrap();
db.conn(),
dir.path(),
"p1",
"Target Post",
Some("target body"),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
publish_post(db.conn(), dir.path(), &target.id).unwrap();
// Create source post with a link to target
let target_url = canonical_url(target.created_at, &target.slug);
let body = format!("Check out [this post]({target_url}) for more.");
let source = create_post(
db.conn(), dir.path(), "p1", "Source Post", Some(&body),
vec![], vec![], None, None, None,
).unwrap();
db.conn(),
dir.path(),
"p1",
"Source Post",
Some(&body),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
publish_post(db.conn(), dir.path(), &source.id).unwrap();
// Verify link was created
@@ -1549,9 +1922,18 @@ mod tests {
fn archive_from_published_status() {
let (db, dir) = setup();
let post = create_post(
db.conn(), dir.path(), "p1", "To Archive", Some("body"),
vec![], vec![], None, None, None,
).unwrap();
db.conn(),
dir.path(),
"p1",
"To Archive",
Some("body"),
vec![],
vec![],
None,
None,
None,
)
.unwrap();
publish_post(db.conn(), dir.path(), &post.id).unwrap();
archive_post(db.conn(), dir.path(), &post.id).unwrap();

View File

@@ -4,9 +4,10 @@ use rusqlite::Connection;
use uuid::Uuid;
use crate::db::queries::media as qm;
use crate::db::queries::post as qp;
use crate::db::queries::post_media as qpm;
use crate::engine::EngineResult;
use crate::model::PostMedia;
use crate::model::{Media, Post, PostMedia};
use crate::util::sidecar::MediaSidecar;
use crate::util::{atomic_write_str, now_unix_ms};
@@ -59,6 +60,30 @@ pub fn reorder_post_media(
Ok(())
}
/// List media items currently linked to a post.
pub fn list_media_for_post(conn: &Connection, post_id: &str) -> EngineResult<Vec<Media>> {
let links = qpm::list_post_media_by_post(conn, post_id)?;
let mut media = Vec::with_capacity(links.len());
for link in links {
if let Ok(item) = qm::get_media_by_id(conn, &link.media_id) {
media.push(item);
}
}
Ok(media)
}
/// List posts currently linked to a media item.
pub fn list_posts_for_media(conn: &Connection, media_id: &str) -> EngineResult<Vec<Post>> {
let links = qpm::list_post_media_by_media(conn, media_id)?;
let mut posts = Vec::with_capacity(links.len());
for link in links {
if let Ok(post) = qp::get_post_by_id(conn, &link.post_id) {
posts.push(post);
}
}
Ok(posts)
}
/// Rebuild the media sidecar file so that `linkedPostIds` reflects the current
/// set of posts linked to this media item.
fn sync_sidecar_linked_post_ids(
@@ -193,4 +218,28 @@ mod tests {
assert!(ids.contains(&"post1".to_string()));
assert!(ids.contains(&"post2".to_string()));
}
#[test]
fn list_media_for_post_returns_resolved_media() {
let (db, dir) = setup();
insert_test_media(&db, dir.path(), "m1");
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
let media = list_media_for_post(db.conn(), "post1").unwrap();
assert_eq!(media.len(), 1);
assert_eq!(media[0].id, "m1");
}
#[test]
fn list_posts_for_media_returns_resolved_posts() {
let (db, dir) = setup();
insert_test_media(&db, dir.path(), "m1");
link_media_to_post(db.conn(), dir.path(), "p1", "post1", "m1", 0).unwrap();
let posts = list_posts_for_media(db.conn(), "m1").unwrap();
assert_eq!(posts.len(), 1);
assert_eq!(posts[0].id, "post1");
}
}