Align legacy post snapshots with bDS2.
This commit is contained in:
@@ -79,75 +79,6 @@ pub fn update_post_status(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_post_content(conn: &DbConnection, id: &str, updated_at: i64) -> QueryResult<()> {
|
|
||||||
conn.with(|c| {
|
|
||||||
diesel::update(posts::table.filter(posts::id.eq(id)))
|
|
||||||
.set((
|
|
||||||
posts::content.eq(None::<String>),
|
|
||||||
posts::updated_at.eq(updated_at),
|
|
||||||
))
|
|
||||||
.execute(c)
|
|
||||||
.map(|_| ())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_post_file_path(
|
|
||||||
conn: &DbConnection,
|
|
||||||
id: &str,
|
|
||||||
file_path: &str,
|
|
||||||
updated_at: i64,
|
|
||||||
) -> QueryResult<()> {
|
|
||||||
conn.with(|c| {
|
|
||||||
diesel::update(posts::table.filter(posts::id.eq(id)))
|
|
||||||
.set((
|
|
||||||
posts::file_path.eq(file_path),
|
|
||||||
posts::updated_at.eq(updated_at),
|
|
||||||
))
|
|
||||||
.execute(c)
|
|
||||||
.map(|_| ())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_post_checksum(conn: &DbConnection, id: &str, checksum: Option<&str>) -> QueryResult<()> {
|
|
||||||
conn.with(|c| {
|
|
||||||
diesel::update(posts::table.filter(posts::id.eq(id)))
|
|
||||||
.set(posts::checksum.eq(checksum))
|
|
||||||
.execute(c)
|
|
||||||
.map(|_| ())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::too_many_arguments,
|
|
||||||
reason = "arguments mirror the published snapshot columns"
|
|
||||||
)]
|
|
||||||
pub fn set_published_snapshot(
|
|
||||||
conn: &DbConnection,
|
|
||||||
id: &str,
|
|
||||||
title: &str,
|
|
||||||
content: &str,
|
|
||||||
tags: &str,
|
|
||||||
categories: &str,
|
|
||||||
excerpt: Option<&str>,
|
|
||||||
published_at: i64,
|
|
||||||
updated_at: i64,
|
|
||||||
) -> QueryResult<()> {
|
|
||||||
conn.with(|c| {
|
|
||||||
diesel::update(posts::table.filter(posts::id.eq(id)))
|
|
||||||
.set((
|
|
||||||
posts::published_title.eq(title),
|
|
||||||
posts::published_content.eq(content),
|
|
||||||
posts::published_tags.eq(tags),
|
|
||||||
posts::published_categories.eq(categories),
|
|
||||||
posts::published_excerpt.eq(excerpt),
|
|
||||||
posts::published_at.eq(published_at),
|
|
||||||
posts::updated_at.eq(updated_at),
|
|
||||||
))
|
|
||||||
.execute(c)
|
|
||||||
.map(|_| ())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_post(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
pub fn delete_post(conn: &DbConnection, id: &str) -> QueryResult<()> {
|
||||||
conn.with(|c| {
|
conn.with(|c| {
|
||||||
diesel::delete(posts::table.filter(posts::id.eq(id)))
|
diesel::delete(posts::table.filter(posts::id.eq(id)))
|
||||||
@@ -545,46 +476,6 @@ mod tests {
|
|||||||
assert!(get_post_by_id(db.conn(), "x1").is_err());
|
assert!(get_post_by_id(db.conn(), "x1").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clear_content_sets_null() {
|
|
||||||
let db = setup();
|
|
||||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
|
||||||
clear_post_content(db.conn(), "x1", 5000).unwrap();
|
|
||||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
|
||||||
assert!(fetched.content.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_file_path_updates() {
|
|
||||||
let db = setup();
|
|
||||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
|
||||||
set_post_file_path(db.conn(), "x1", "posts/2024/01/hello.md", 5000).unwrap();
|
|
||||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
|
||||||
assert_eq!(fetched.file_path, "posts/2024/01/hello.md");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn published_snapshot() {
|
|
||||||
let db = setup();
|
|
||||||
insert_post(db.conn(), &make_post("x1", "hello")).unwrap();
|
|
||||||
set_published_snapshot(
|
|
||||||
db.conn(),
|
|
||||||
"x1",
|
|
||||||
"Pub Title",
|
|
||||||
"Pub Body",
|
|
||||||
"[\"rust\"]",
|
|
||||||
"[\"tech\"]",
|
|
||||||
Some("Pub Excerpt"),
|
|
||||||
3000,
|
|
||||||
3000,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let fetched = get_post_by_id(db.conn(), "x1").unwrap();
|
|
||||||
assert_eq!(fetched.published_title.as_deref(), Some("Pub Title"));
|
|
||||||
assert_eq!(fetched.published_content.as_deref(), Some("Pub Body"));
|
|
||||||
assert_eq!(fetched.published_at, Some(3000));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn delete_removes_post() {
|
fn delete_removes_post() {
|
||||||
let db = setup();
|
let db = setup();
|
||||||
|
|||||||
@@ -327,37 +327,9 @@ fn publish_post_in_savepoint(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set published snapshot fields
|
// Persist the published record without touching the legacy published_* columns.
|
||||||
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());
|
|
||||||
post.published_title = Some(post.title.clone());
|
|
||||||
post.published_content = Some(body.clone());
|
|
||||||
post.published_tags = Some(tags_json.clone());
|
|
||||||
post.published_categories = Some(cats_json.clone());
|
|
||||||
post.published_excerpt = post.excerpt.clone();
|
|
||||||
|
|
||||||
qp::set_published_snapshot(
|
|
||||||
conn,
|
|
||||||
&post_id,
|
|
||||||
&post.title,
|
|
||||||
&body,
|
|
||||||
&tags_json,
|
|
||||||
&cats_json,
|
|
||||||
post.excerpt.as_deref(),
|
|
||||||
published_at,
|
|
||||||
now,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Set file_path and checksum in DB
|
|
||||||
qp::set_post_file_path(conn, &post_id, &post.file_path, now)?;
|
|
||||||
qp::set_post_checksum(conn, &post_id, post.checksum.as_deref())?;
|
|
||||||
|
|
||||||
// Clear content in DB
|
|
||||||
qp::clear_post_content(conn, &post_id, now)?;
|
|
||||||
post.content = None;
|
post.content = None;
|
||||||
|
qp::update_post(conn, &post)?;
|
||||||
// Set status = Published
|
|
||||||
qp::update_post_status(conn, &post_id, &PostStatus::Published, now)?;
|
|
||||||
|
|
||||||
// Publish all translations
|
// Publish all translations
|
||||||
let translations = qt::list_post_translations_by_post(conn, &post_id)?;
|
let translations = qt::list_post_translations_by_post(conn, &post_id)?;
|
||||||
@@ -1638,6 +1610,53 @@ mod tests {
|
|||||||
assert_eq!(updated.content, None);
|
assert_eq!(updated.content, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn published_updates_ignore_legacy_snapshot_values() {
|
||||||
|
let (db, dir) = setup();
|
||||||
|
let mut post = create_published_post(&db, &dir, "Published", "canonical body");
|
||||||
|
post.published_title = Some("Different Legacy Title".into());
|
||||||
|
post.published_content = Some("replacement body".into());
|
||||||
|
post.published_tags = Some("[\"legacy\"]".into());
|
||||||
|
qp::update_post(db.conn(), &post).unwrap();
|
||||||
|
|
||||||
|
let identical = update_post(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
&post.id,
|
||||||
|
Some("Published"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("canonical body"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(identical.status, PostStatus::Published);
|
||||||
|
|
||||||
|
let changed = update_post(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
&post.id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("replacement body"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(changed.status, PostStatus::Draft);
|
||||||
|
assert_eq!(changed.content.as_deref(), Some("replacement body"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn published_title_change_reopens_draft_with_file_body() {
|
fn published_title_change_reopens_draft_with_file_body() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
@@ -1809,9 +1828,12 @@ mod tests {
|
|||||||
// published_at should be set
|
// published_at should be set
|
||||||
assert!(from_db.published_at.is_some());
|
assert!(from_db.published_at.is_some());
|
||||||
|
|
||||||
// Published snapshot fields should be set
|
// Legacy published snapshot columns stay empty, matching bDS2.
|
||||||
assert_eq!(from_db.published_title.as_deref(), Some("Publish Me"));
|
assert!(from_db.published_title.is_none());
|
||||||
assert!(from_db.published_content.is_some());
|
assert!(from_db.published_content.is_none());
|
||||||
|
assert!(from_db.published_tags.is_none());
|
||||||
|
assert!(from_db.published_categories.is_none());
|
||||||
|
assert!(from_db.published_excerpt.is_none());
|
||||||
|
|
||||||
// File should exist on disk
|
// File should exist on disk
|
||||||
let abs_path = dir.path().join(&from_db.file_path);
|
let abs_path = dir.path().join(&from_db.file_path);
|
||||||
@@ -1823,6 +1845,39 @@ mod tests {
|
|||||||
assert!(file_content.contains("Publish Me"));
|
assert!(file_content.contains("Publish Me"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_post_preserves_legacy_snapshot_values_without_using_them() {
|
||||||
|
let (db, dir) = setup();
|
||||||
|
let mut post = create_post(
|
||||||
|
db.conn(),
|
||||||
|
dir.path(),
|
||||||
|
"p1",
|
||||||
|
"Publish Me",
|
||||||
|
Some("body"),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
post.published_title = Some("Legacy Title".into());
|
||||||
|
post.published_content = Some("Legacy Body".into());
|
||||||
|
post.published_tags = Some("[\"legacy\"]".into());
|
||||||
|
post.published_categories = Some("[\"old\"]".into());
|
||||||
|
post.published_excerpt = Some("Legacy Excerpt".into());
|
||||||
|
qp::update_post(db.conn(), &post).unwrap();
|
||||||
|
|
||||||
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
|
let published = qp::get_post_by_id(db.conn(), &post.id).unwrap();
|
||||||
|
assert_eq!(published.published_title, post.published_title);
|
||||||
|
assert_eq!(published.published_content, post.published_content);
|
||||||
|
assert_eq!(published.published_tags, post.published_tags);
|
||||||
|
assert_eq!(published.published_categories, post.published_categories);
|
||||||
|
assert_eq!(published.published_excerpt, post.published_excerpt);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_replaces_divergent_post_path_and_ignores_missing_old_file() {
|
fn publish_replaces_divergent_post_path_and_ignores_missing_old_file() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
@@ -1988,7 +2043,7 @@ mod tests {
|
|||||||
let discarded = discard_post_draft(db.conn(), dir.path(), &post.id).unwrap();
|
let discarded = discard_post_draft(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
assert_eq!(discarded.status, PostStatus::Published);
|
assert_eq!(discarded.status, PostStatus::Published);
|
||||||
assert_eq!(discarded.title, published.title);
|
assert_eq!(discarded.title, published.title);
|
||||||
assert_eq!(discarded.excerpt, published.published_excerpt);
|
assert_eq!(discarded.excerpt, published.excerpt);
|
||||||
assert_eq!(discarded.tags, vec!["one"]);
|
assert_eq!(discarded.tags, vec!["one"]);
|
||||||
assert_eq!(discarded.categories, vec!["cat"]);
|
assert_eq!(discarded.categories, vec!["cat"]);
|
||||||
assert_eq!(discarded.content, None);
|
assert_eq!(discarded.content, None);
|
||||||
|
|||||||
@@ -598,16 +598,13 @@ pub fn analyze_wxr(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn existing_post_body(post: &Post, data_dir: &Path) -> Option<String> {
|
fn existing_post_body(post: &Post, data_dir: &Path) -> Option<String> {
|
||||||
post.content
|
post.content.clone().or_else(|| {
|
||||||
.clone()
|
(!post.file_path.is_empty())
|
||||||
.or_else(|| post.published_content.clone())
|
.then(|| fs::read_to_string(data_dir.join(&post.file_path)).ok())
|
||||||
.or_else(|| {
|
.flatten()
|
||||||
(!post.file_path.is_empty())
|
.and_then(|raw| crate::util::frontmatter::read_post_file(&raw).ok())
|
||||||
.then(|| fs::read_to_string(data_dir.join(&post.file_path)).ok())
|
.map(|(_, body)| body)
|
||||||
.flatten()
|
})
|
||||||
.and_then(|raw| crate::util::frontmatter::read_post_file(&raw).ok())
|
|
||||||
.map(|(_, body)| body)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn analyze_post(
|
fn analyze_post(
|
||||||
@@ -1363,7 +1360,7 @@ fn import_post_item(
|
|||||||
qp::update_post(conn, &imported)?;
|
qp::update_post(conn, &imported)?;
|
||||||
if item.source_status.as_deref() == Some("publish") {
|
if item.source_status.as_deref() == Some("publish") {
|
||||||
imported = post::publish_post(conn, data_dir, &imported.id)?;
|
imported = post::publish_post(conn, data_dir, &imported.id)?;
|
||||||
let body = imported.published_content.clone().unwrap_or_default();
|
let body = content.to_string();
|
||||||
imported.created_at = item.created_at.unwrap_or(imported.created_at);
|
imported.created_at = item.created_at.unwrap_or(imported.created_at);
|
||||||
imported.updated_at = item.updated_at.unwrap_or(imported.created_at);
|
imported.updated_at = item.updated_at.unwrap_or(imported.created_at);
|
||||||
imported.published_at = item.published_at.or(Some(imported.created_at));
|
imported.published_at = item.published_at.or(Some(imported.created_at));
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ pub struct Post {
|
|||||||
serialize_as = crate::db::types::DbStringList
|
serialize_as = crate::db::types::DbStringList
|
||||||
)]
|
)]
|
||||||
pub categories: Vec<String>,
|
pub categories: Vec<String>,
|
||||||
// Published snapshot fields (used for diff detection)
|
// Legacy bDS2-compatible columns. Publishing and lifecycle logic leave them untouched.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub published_title: Option<String>,
|
pub published_title: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -635,8 +635,12 @@ fn overwrite_resolution_preserves_existing_post_and_media_identity() {
|
|||||||
bds_core::db::queries::post::get_post_by_id(db.conn(), &existing_post.id).unwrap();
|
bds_core::db::queries::post::get_post_by_id(db.conn(), &existing_post.id).unwrap();
|
||||||
assert_eq!(overwritten_post.id, existing_post.id);
|
assert_eq!(overwritten_post.id, existing_post.id);
|
||||||
assert_eq!(overwritten_post.status, PostStatus::Published);
|
assert_eq!(overwritten_post.status, PostStatus::Published);
|
||||||
|
assert!(overwritten_post.published_content.is_none());
|
||||||
|
let (_, overwritten_body) =
|
||||||
|
read_post_file(&fs::read_to_string(dir.path().join(&overwritten_post.file_path)).unwrap())
|
||||||
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
overwritten_post.published_content.as_deref(),
|
Some(overwritten_body.as_str()),
|
||||||
report.posts[0].content.as_deref()
|
report.posts[0].content.as_deref()
|
||||||
);
|
);
|
||||||
let overwritten_media =
|
let overwritten_media =
|
||||||
|
|||||||
@@ -107,9 +107,9 @@ entity Post {
|
|||||||
updated_at: Timestamp
|
updated_at: Timestamp
|
||||||
published_at: Timestamp?
|
published_at: Timestamp?
|
||||||
|
|
||||||
-- Published snapshot: copy of title/content/tags/categories/excerpt as of
|
-- Legacy bDS2-compatible columns. Publishing does not populate them and
|
||||||
-- the last publish. Used by changes_affect_published_content to decide when
|
-- application behaviour does not depend on them; imported non-null values
|
||||||
-- an edit reopens a published post to draft (see ReopenPublishedPost).
|
-- remain passive database data.
|
||||||
published_title: String?
|
published_title: String?
|
||||||
published_content: String?
|
published_content: String?
|
||||||
published_tags: String?
|
published_tags: String?
|
||||||
@@ -200,6 +200,9 @@ rule ReopenPublishedPost {
|
|||||||
when: UpdatePostRequested(post, changes)
|
when: UpdatePostRequested(post, changes)
|
||||||
requires: post.status = published
|
requires: post.status = published
|
||||||
requires: changes_affect_published_content(changes)
|
requires: changes_affect_published_content(changes)
|
||||||
|
-- Compares supplied metadata with the current post fields and supplied
|
||||||
|
-- content with the canonical file body. Legacy published_* columns are
|
||||||
|
-- not consulted because bDS2 does not populate them.
|
||||||
ensures: post.status = draft
|
ensures: post.status = draft
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ entity Post {
|
|||||||
language: String? -- ISO 639-1 code
|
language: String? -- ISO 639-1 code
|
||||||
do_not_translate: Boolean
|
do_not_translate: Boolean
|
||||||
|
|
||||||
-- Published snapshot columns (written on publish for diff detection)
|
-- Legacy compatibility columns. bDS2 and RuDS leave them unchanged on
|
||||||
|
-- publish and do not use them to determine lifecycle behaviour.
|
||||||
published_title: String?
|
published_title: String?
|
||||||
published_content: String?
|
published_content: String?
|
||||||
published_tags: String?
|
published_tags: String?
|
||||||
|
|||||||
Reference in New Issue
Block a user