Resync media sidecars after post deletion.
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ pub fn list_post_media_by_post(conn: &DbConnection, post_id: &str) -> QueryResul
|
|||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
post_media::table
|
post_media::table
|
||||||
.filter(post_media::post_id.eq(post_id))
|
.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())
|
.select(PostMedia::as_select())
|
||||||
.load(c)
|
.load(c)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -338,12 +338,7 @@ pub fn update_media(
|
|||||||
media.updated_at = now_unix_ms();
|
media.updated_at = now_unix_ms();
|
||||||
qm::update_media(conn, &media)?;
|
qm::update_media(conn, &media)?;
|
||||||
|
|
||||||
// Rewrite sidecar (need linked_post_ids from post_media table)
|
sync_media_sidecar(conn, data_dir, media_id)?;
|
||||||
let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
|
|
||||||
let linked_post_ids: Vec<String> = 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())?;
|
|
||||||
|
|
||||||
// Re-index FTS
|
// Re-index FTS
|
||||||
fts_index_media(conn, &media)?;
|
fts_index_media(conn, &media)?;
|
||||||
@@ -353,6 +348,21 @@ pub fn update_media(
|
|||||||
Ok(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::<Vec<_>>();
|
||||||
|
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,
|
/// Replace a media item's binary while preserving its identity, public path,
|
||||||
/// metadata, translations, and post links. Returns `None` when the new file is
|
/// metadata, translations, and post links. Returns `None` when the new file is
|
||||||
/// byte-for-byte identical to the stored binary.
|
/// byte-for-byte identical to the stored binary.
|
||||||
@@ -432,10 +442,7 @@ pub fn replace_media_file(
|
|||||||
let apply_result = (|| -> EngineResult<()> {
|
let apply_result = (|| -> EngineResult<()> {
|
||||||
conn.begin_savepoint()?;
|
conn.begin_savepoint()?;
|
||||||
qm::update_media(conn, &media)?;
|
qm::update_media(conn, &media)?;
|
||||||
let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
|
sync_media_sidecar(conn, data_dir, media_id)?;
|
||||||
let linked_post_ids: Vec<String> = 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())?;
|
|
||||||
fts_index_media(conn, &media)?;
|
fts_index_media(conn, &media)?;
|
||||||
|
|
||||||
for staged in &staged_paths {
|
for staged in &staged_paths {
|
||||||
|
|||||||
@@ -545,6 +545,10 @@ pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) ->
|
|||||||
/// Delete a post and all related data.
|
/// Delete a post and all related data.
|
||||||
pub fn delete_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<()> {
|
pub fn delete_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<()> {
|
||||||
let post = qp::get_post_by_id(conn, post_id)?;
|
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::<Vec<_>>();
|
||||||
|
|
||||||
// Delete .md file if exists
|
// Delete .md file if exists
|
||||||
if !post.file_path.is_empty() {
|
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
|
// Delete post from DB
|
||||||
qp::delete_post(conn, post_id)?;
|
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);
|
crate::engine::embedding::remove_post_best_effort(conn, data_dir, &post.project_id, post_id);
|
||||||
|
|
||||||
emit_post(&post, NotificationAction::Deleted);
|
emit_post(&post, NotificationAction::Deleted);
|
||||||
@@ -2262,6 +2273,94 @@ mod tests {
|
|||||||
assert!(!post_file.exists());
|
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]
|
#[test]
|
||||||
fn upsert_translation_create_and_update() {
|
fn upsert_translation_create_and_update() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ use crate::db::queries::post as qp;
|
|||||||
use crate::db::queries::post_media as qpm;
|
use crate::db::queries::post_media as qpm;
|
||||||
use crate::engine::EngineResult;
|
use crate::engine::EngineResult;
|
||||||
use crate::model::{Media, Post, PostMedia};
|
use crate::model::{Media, Post, PostMedia};
|
||||||
use crate::util::sidecar::MediaSidecar;
|
use crate::util::now_unix_ms;
|
||||||
use crate::util::{atomic_write_str, now_unix_ms};
|
|
||||||
|
|
||||||
/// Link a media item to a post and sync the media sidecar.
|
/// Link a media item to a post and sync the media sidecar.
|
||||||
pub fn link_media_to_post(
|
pub fn link_media_to_post(
|
||||||
@@ -31,7 +30,7 @@ pub fn link_media_to_post(
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
};
|
};
|
||||||
qpm::link_media(conn, &pm)?;
|
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)
|
Ok(pm)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ pub fn unlink_media_from_post(
|
|||||||
media_id: &str,
|
media_id: &str,
|
||||||
) -> EngineResult<()> {
|
) -> EngineResult<()> {
|
||||||
qpm::unlink_media(conn, post_id, media_id)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,22 +83,6 @@ pub fn list_posts_for_media(conn: &Connection, media_id: &str) -> EngineResult<V
|
|||||||
Ok(posts)
|
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(
|
|
||||||
conn: &Connection,
|
|
||||||
data_dir: &Path,
|
|
||||||
media_id: &str,
|
|
||||||
) -> EngineResult<()> {
|
|
||||||
let links = qpm::list_post_media_by_media(conn, media_id)?;
|
|
||||||
let post_ids: Vec<String> = 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -112,6 +95,7 @@ mod tests {
|
|||||||
use crate::db::queries::post::{insert_post, make_test_post};
|
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::post_media::list_post_media_by_post;
|
||||||
use crate::db::queries::project::{insert_project, make_test_project};
|
use crate::db::queries::project::{insert_project, make_test_project};
|
||||||
|
use crate::util::sidecar::MediaSidecar;
|
||||||
use crate::util::sidecar::read_sidecar;
|
use crate::util::sidecar::read_sidecar;
|
||||||
|
|
||||||
fn setup() -> (Database, TempDir) {
|
fn setup() -> (Database, TempDir) {
|
||||||
|
|||||||
Reference in New Issue
Block a user