From 53ada2c7054d8405cb5c7de58d4cfc87d7c40cd3 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 21 Jul 2026 22:31:01 +0200 Subject: [PATCH] Remove stale post files after path changes. --- README.md | 2 +- crates/bds-core/src/engine/post.rs | 100 +++++++++++++++++++++++++++++ crates/bds-ui/src/app.rs | 38 +++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c6e9648..b04b9b4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The project is under active development. Core blogging workflows are broadly ava - Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. - Post, Liquid template, and Lua script editing with dedicated syntax highlighting and explicit syntax-check feedback, including publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync. -- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, bDS2-compatible checksums and NFD slug generation, and FTS5 search. +- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, stale post-path cleanup on republish, bDS2-compatible checksums and NFD slug generation, and FTS5 search. - Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace. - Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links. - A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence. diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index b1869d0..9e5cc81 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -292,6 +292,7 @@ fn publish_post_in_savepoint( mut post: Post, ) -> EngineResult { let post_id = post.id.clone(); + let old_rel_path = post.file_path.clone(); // Compute file_path from created_at + slug. let rel_path = post_file_path(post.created_at, &post.slug); @@ -318,6 +319,13 @@ fn publish_post_in_savepoint( let file_content = write_post_file(&post, &body); atomic_write_str(&abs_path, &file_content)?; + if !old_rel_path.is_empty() && old_rel_path != rel_path { + match fs::remove_file(data_dir.join(&old_rel_path)) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } // Set published snapshot fields let tags_json = serde_json::to_string(&post.tags).unwrap_or_else(|_| "[]".into()); @@ -1866,6 +1874,98 @@ mod tests { assert!(file_content.contains("Publish Me")); } + #[test] + fn publish_replaces_divergent_post_path_and_ignores_missing_old_file() { + let (db, dir) = setup(); + let mut post = create_post( + db.conn(), + dir.path(), + "p1", + "Moved Post", + Some("current body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + post.file_path = "posts/legacy/moved-post.md".to_string(); + qp::update_post(db.conn(), &post).unwrap(); + let old_path = dir.path().join(&post.file_path); + fs::create_dir_all(old_path.parent().unwrap()).unwrap(); + fs::write(&old_path, "stale legacy file").unwrap(); + + let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); + + assert_ne!(published.file_path, post.file_path); + assert!(!old_path.exists()); + assert!(dir.path().join(&published.file_path).is_file()); + assert_eq!( + qp::get_post_by_id(db.conn(), &post.id).unwrap().file_path, + published.file_path + ); + + let mut missing = create_post( + db.conn(), + dir.path(), + "p1", + "Missing Legacy Post", + Some("body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + missing.file_path = "posts/legacy/already-gone.md".to_string(); + qp::update_post(db.conn(), &missing).unwrap(); + + let published_missing = publish_post(db.conn(), dir.path(), &missing.id).unwrap(); + assert!(dir.path().join(&published_missing.file_path).is_file()); + } + + #[test] + fn translation_publish_retains_divergent_old_file_like_bds2() { + let (db, dir) = setup(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Translated Move", + Some("body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + let mut translation = upsert_translation( + db.conn(), + dir.path(), + &post.id, + "de", + "Verschoben", + None, + Some("Inhalt"), + ) + .unwrap(); + translation.file_path = "posts/legacy/translated-move.de.md".to_string(); + qt::update_post_translation(db.conn(), &translation).unwrap(); + let old_path = dir.path().join(&translation.file_path); + fs::create_dir_all(old_path.parent().unwrap()).unwrap(); + fs::write(&old_path, "legacy translation").unwrap(); + + publish_post(db.conn(), dir.path(), &post.id).unwrap(); + + let published = qt::get_post_translation_by_id(db.conn(), &translation.id).unwrap(); + assert_ne!(published.file_path, translation.file_path); + assert!(old_path.is_file()); + assert!(dir.path().join(&published.file_path).is_file()); + } + #[test] fn discard_post_draft_restores_published_state() { let (db, dir) = setup(); diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 922f552..da6c153 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -10863,6 +10863,44 @@ mod tests { assert_eq!(refreshed.content, "Neuer Entwurf"); } + #[test] + fn post_editor_publish_removes_a_divergent_old_post_file() { + let (db, project, tmp) = setup(); + let mut created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Moved Through Editor", + Some("Body"), + Vec::new(), + Vec::new(), + None, + Some("en"), + None, + ) + .unwrap(); + created.file_path = "posts/legacy/editor-old.md".to_string(); + bds_core::db::queries::post::update_post(db.conn(), &created).unwrap(); + let old_path = tmp.path().join(&created.file_path); + std::fs::create_dir_all(old_path.parent().unwrap()).unwrap(); + std::fs::write(&old_path, "old file").unwrap(); + let mut app = make_app(db, project, &tmp); + open_post_editor(&mut app, &created); + + let _ = app.publish_post_editor(&created.id); + + let published = bds_core::db::queries::post::get_post_by_id( + app.db.as_ref().unwrap().conn(), + &created.id, + ) + .unwrap(); + assert_eq!(published.status, PostStatus::Published); + assert_ne!(published.file_path, created.file_path); + assert!(!old_path.exists()); + assert!(tmp.path().join(&published.file_path).is_file()); + assert_eq!(app.post_editors[&created.id].status, PostStatus::Published); + } + #[test] fn persist_post_editor_state_allows_published_posts_without_slug_conflict() { let (db, project, tmp) = setup();