From 1cdc86b9cc8f6c6d38c53e16f11b0aa6889d4b09 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 21 Jul 2026 22:38:53 +0200 Subject: [PATCH] Resync media sidecars after post deletion. --- README.md | 2 +- crates/bds-core/src/db/queries/post_media.rs | 2 +- crates/bds-core/src/engine/media.rs | 27 ++++-- crates/bds-core/src/engine/post.rs | 99 ++++++++++++++++++++ crates/bds-core/src/engine/post_media.rs | 24 +---- 5 files changed, 122 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index b04b9b4..a2125f4 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, stale post-path cleanup on republish, bDS2-compatible checksums and NFD slug generation, and FTS5 search. +- SQLite and filesystem persistence with frontmatter, relationship-safe media 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/db/queries/post_media.rs b/crates/bds-core/src/db/queries/post_media.rs index 27ddb81..a198d63 100644 --- a/crates/bds-core/src/db/queries/post_media.rs +++ b/crates/bds-core/src/db/queries/post_media.rs @@ -37,7 +37,7 @@ pub fn list_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResul conn.with(|c| { post_media::table .filter(post_media::post_id.eq(post_id)) - .order(post_media::sort_order) + .order((post_media::sort_order.asc(), post_media::media_id.asc())) .select(PostMedia::as_select()) .load(c) }) diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs index 4290851..bb229da 100644 --- a/crates/bds-core/src/engine/media.rs +++ b/crates/bds-core/src/engine/media.rs @@ -338,12 +338,7 @@ pub fn update_media( media.updated_at = now_unix_ms(); qm::update_media(conn, &media)?; - // Rewrite sidecar (need linked_post_ids from post_media table) - let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default(); - let linked_post_ids: Vec = linked.iter().map(|pm| pm.post_id.clone()).collect(); - let sidecar = MediaSidecar::from_media(&media, &linked_post_ids); - let abs_sidecar = data_dir.join(&media.sidecar_path); - atomic_write_str(&abs_sidecar, &sidecar.to_string())?; + sync_media_sidecar(conn, data_dir, media_id)?; // Re-index FTS fts_index_media(conn, &media)?; @@ -353,6 +348,21 @@ pub fn update_media( Ok(media) } +/// Rewrite a media item's canonical sidecar from its current database state. +pub fn sync_media_sidecar(conn: &Connection, data_dir: &Path, media_id: &str) -> EngineResult<()> { + let media = qm::get_media_by_id(conn, media_id).map_err(|error| match error { + diesel::result::Error::NotFound => EngineError::NotFound(format!("media {media_id}")), + error => EngineError::Db(error), + })?; + let linked_post_ids = qpm::list_post_media_by_media(conn, media_id)? + .into_iter() + .map(|link| link.post_id) + .collect::>(); + let sidecar = MediaSidecar::from_media(&media, &linked_post_ids); + atomic_write_str(&data_dir.join(&media.sidecar_path), &sidecar.to_string())?; + Ok(()) +} + /// Replace a media item's binary while preserving its identity, public path, /// metadata, translations, and post links. Returns `None` when the new file is /// byte-for-byte identical to the stored binary. @@ -432,10 +442,7 @@ pub fn replace_media_file( let apply_result = (|| -> EngineResult<()> { conn.begin_savepoint()?; qm::update_media(conn, &media)?; - let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default(); - let linked_post_ids: Vec = linked.iter().map(|item| item.post_id.clone()).collect(); - let sidecar = MediaSidecar::from_media(&media, &linked_post_ids); - atomic_write_str(&data_dir.join(&media.sidecar_path), &sidecar.to_string())?; + sync_media_sidecar(conn, data_dir, media_id)?; fts_index_media(conn, &media)?; for staged in &staged_paths { diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index 9e5cc81..ca35c4b 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -545,6 +545,10 @@ pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) -> /// 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)?; + let linked_media_ids = crate::db::queries::post_media::list_post_media_by_post(conn, post_id)? + .into_iter() + .map(|link| link.media_id) + .collect::>(); // Delete .md file if exists if !post.file_path.is_empty() { @@ -581,6 +585,13 @@ pub fn delete_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineR // Delete post from DB qp::delete_post(conn, post_id)?; + for media_id in linked_media_ids { + match crate::engine::media::sync_media_sidecar(conn, data_dir, &media_id) { + Ok(()) | Err(EngineError::NotFound(_)) => {} + Err(error) => return Err(error), + } + } + crate::engine::embedding::remove_post_best_effort(conn, data_dir, &post.project_id, post_id); emit_post(&post, NotificationAction::Deleted); @@ -2262,6 +2273,94 @@ mod tests { assert!(!post_file.exists()); } + #[test] + fn delete_post_removes_post_from_linked_media_sidecars() { + let (db, dir) = setup(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Delete Linked Post", + Some("body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + publish_post(db.conn(), dir.path(), &post.id).unwrap(); + let surviving_post = create_post( + db.conn(), + dir.path(), + "p1", + "Keep Linked Post", + Some("body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + + let insert_media = |id: &str| { + let mut media = crate::db::queries::media::make_test_media(id, "p1"); + media.file_path = format!("media/{id}.jpg"); + media.sidecar_path = format!("media/{id}.jpg.meta"); + crate::db::queries::media::insert_media(db.conn(), &media).unwrap(); + let sidecar_path = dir.path().join(&media.sidecar_path); + fs::create_dir_all(sidecar_path.parent().unwrap()).unwrap(); + fs::write( + &sidecar_path, + crate::util::sidecar::MediaSidecar::from_media(&media, &[]).to_string(), + ) + .unwrap(); + (media, sidecar_path) + }; + let (first_media, first_sidecar_path) = insert_media("media1"); + let (second_media, second_sidecar_path) = insert_media("media2"); + + crate::engine::post_media::link_media_to_post( + db.conn(), + dir.path(), + "p1", + &post.id, + &first_media.id, + 0, + ) + .unwrap(); + crate::engine::post_media::link_media_to_post( + db.conn(), + dir.path(), + "p1", + &surviving_post.id, + &first_media.id, + 0, + ) + .unwrap(); + crate::engine::post_media::link_media_to_post( + db.conn(), + dir.path(), + "p1", + &post.id, + &second_media.id, + 1, + ) + .unwrap(); + + delete_post(db.conn(), dir.path(), &post.id).unwrap(); + + let first_sidecar = + crate::util::sidecar::read_sidecar(&fs::read_to_string(&first_sidecar_path).unwrap()) + .unwrap(); + assert_eq!(first_sidecar.linked_post_ids, vec![surviving_post.id]); + let second_sidecar = + crate::util::sidecar::read_sidecar(&fs::read_to_string(&second_sidecar_path).unwrap()) + .unwrap(); + assert!(second_sidecar.linked_post_ids.is_empty()); + } + #[test] fn upsert_translation_create_and_update() { let (db, dir) = setup(); diff --git a/crates/bds-core/src/engine/post_media.rs b/crates/bds-core/src/engine/post_media.rs index e175394..4f236fe 100644 --- a/crates/bds-core/src/engine/post_media.rs +++ b/crates/bds-core/src/engine/post_media.rs @@ -8,8 +8,7 @@ use crate::db::queries::post as qp; use crate::db::queries::post_media as qpm; use crate::engine::EngineResult; use crate::model::{Media, Post, PostMedia}; -use crate::util::sidecar::MediaSidecar; -use crate::util::{atomic_write_str, now_unix_ms}; +use crate::util::now_unix_ms; /// Link a media item to a post and sync the media sidecar. pub fn link_media_to_post( @@ -31,7 +30,7 @@ pub fn link_media_to_post( created_at: now, }; qpm::link_media(conn, &pm)?; - sync_sidecar_linked_post_ids(conn, data_dir, media_id)?; + crate::engine::media::sync_media_sidecar(conn, data_dir, media_id)?; Ok(pm) } @@ -43,7 +42,7 @@ pub fn unlink_media_from_post( media_id: &str, ) -> EngineResult<()> { qpm::unlink_media(conn, post_id, media_id)?; - sync_sidecar_linked_post_ids(conn, data_dir, media_id)?; + crate::engine::media::sync_media_sidecar(conn, data_dir, media_id)?; Ok(()) } @@ -84,22 +83,6 @@ pub fn list_posts_for_media(conn: &Connection, media_id: &str) -> EngineResult EngineResult<()> { - let links = qpm::list_post_media_by_media(conn, media_id)?; - let post_ids: Vec = links.iter().map(|pm| pm.post_id.clone()).collect(); - let media = qm::get_media_by_id(conn, media_id)?; - let sidecar = MediaSidecar::from_media(&media, &post_ids); - let abs_path = data_dir.join(&media.sidecar_path); - atomic_write_str(&abs_path, &sidecar.to_string())?; - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -112,6 +95,7 @@ mod tests { use crate::db::queries::post::{insert_post, make_test_post}; use crate::db::queries::post_media::list_post_media_by_post; use crate::db::queries::project::{insert_project, make_test_project}; + use crate::util::sidecar::MediaSidecar; use crate::util::sidecar::read_sidecar; fn setup() -> (Database, TempDir) {