Restore discarded posts from canonical files.

This commit is contained in:
2026-07-21 22:45:59 +02:00
parent 1cdc86b9cc
commit 535303e8b5
2 changed files with 132 additions and 98 deletions

View File

@@ -7,7 +7,7 @@ The project is under active development. Core blogging workflows are broadly ava
## Available Features
- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor.
- Post and translation authoring with change-aware draft/published/archive lifecycle, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, media, and batch gallery-image import.
- Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, media, and batch gallery-image import.
- 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.

View File

@@ -422,109 +422,43 @@ fn restore_content_for_unarchive(data_dir: &Path, post: &Post) -> String {
})
}
/// Discard unpublished draft changes and restore the published version.
/// Discard database changes and restore the canonical file version.
pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) -> 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(),
));
if post.file_path.is_empty() {
return Err(EngineError::NotFound(format!(
"canonical file for post {post_id}"
)));
}
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,
None,
)
let abs_path = data_dir.join(&post.file_path);
let raw = fs::read_to_string(&abs_path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
EngineError::NotFound(format!("canonical file for post {post_id}"))
} 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(),
)
EngineError::Io(error)
}
} 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(),
)
};
})?;
let (frontmatter, body) = read_post_file(&raw).map_err(EngineError::Parse)?;
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.title = frontmatter.title;
post.slug = frontmatter.slug;
post.excerpt = frontmatter.excerpt;
post.author = frontmatter.author;
post.language = frontmatter.language;
post.template_slug = frontmatter.template_slug;
post.do_not_translate = frontmatter.do_not_translate;
post.tags = frontmatter.tags;
post.categories = frontmatter.categories;
post.content = None;
post.status = PostStatus::Published;
post.checksum = checksum;
post.updated_at = now_unix_ms();
post.status = post_status_from_frontmatter(&frontmatter.status);
post.checksum = None;
post.created_at = frontmatter.created_at;
post.updated_at = frontmatter.updated_at;
post.published_at = frontmatter.published_at;
conn.begin_savepoint()?;
match (|| {
qp::update_post(conn, &post)?;
let body = resolve_post_fts_content(data_dir, &post)?.unwrap_or_default();
sync_post_links(conn, &post, &body)?;
fts_index_post(conn, data_dir, &post)?;
Ok(post)
@@ -542,6 +476,14 @@ pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) ->
}
}
fn post_status_from_frontmatter(status: &str) -> PostStatus {
match status {
"published" => PostStatus::Published,
"archived" => PostStatus::Archived,
_ => PostStatus::Draft,
}
}
/// 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)?;
@@ -1174,11 +1116,7 @@ pub(crate) fn rebuild_canonical_post(
.to_string_lossy()
.to_string();
let status = match fm.status.as_str() {
"published" => PostStatus::Published,
"archived" => PostStatus::Archived,
_ => PostStatus::Draft,
};
let status = post_status_from_frontmatter(&fm.status);
// Check if post exists in DB by id
let existing = qp::get_post_by_id(conn, &fm.id);
@@ -2061,6 +1999,102 @@ mod tests {
assert!(!discarded.do_not_translate);
}
#[test]
fn discard_post_draft_requires_the_canonical_file() {
let (db, dir) = setup();
let post = create_published_post(&db, &dir, "Published Title", "published body");
let draft = update_post(
db.conn(),
dir.path(),
&post.id,
Some("Draft Title"),
None,
None,
Some("draft body"),
None,
None,
None,
None,
None,
None,
)
.unwrap();
fs::remove_file(dir.path().join(&draft.file_path)).unwrap();
let result = discard_post_draft(db.conn(), dir.path(), &post.id);
assert!(matches!(result, Err(EngineError::NotFound(_))));
let unchanged = qp::get_post_by_id(db.conn(), &post.id).unwrap();
assert_eq!(unchanged.title, "Draft Title");
assert_eq!(unchanged.content.as_deref(), Some("draft body"));
assert_eq!(unchanged.status, PostStatus::Draft);
}
#[test]
fn discard_post_draft_uses_the_file_instead_of_published_at_as_guard() {
let (db, dir) = setup();
let post = create_published_post(&db, &dir, "Published Title", "published body");
let mut draft = update_post(
db.conn(),
dir.path(),
&post.id,
Some("Draft Title"),
None,
None,
Some("draft body"),
None,
None,
None,
None,
None,
None,
)
.unwrap();
draft.published_at = None;
qp::update_post(db.conn(), &draft).unwrap();
let discarded = discard_post_draft(db.conn(), dir.path(), &post.id).unwrap();
assert_eq!(discarded.title, "Published Title");
assert_eq!(discarded.content, None);
assert_eq!(discarded.status, PostStatus::Published);
assert_eq!(discarded.published_at, post.published_at);
}
#[test]
fn discard_post_draft_restores_status_from_frontmatter() {
let (db, dir) = setup();
let post = create_published_post(&db, &dir, "Published Title", "published body");
update_post(
db.conn(),
dir.path(),
&post.id,
Some("Draft Title"),
None,
None,
Some("draft body"),
None,
None,
None,
None,
None,
None,
)
.unwrap();
let post_path = dir.path().join(&post.file_path);
let file = fs::read_to_string(&post_path).unwrap();
fs::write(
&post_path,
file.replacen("status: published", "status: archived", 1),
)
.unwrap();
let discarded = discard_post_draft(db.conn(), dir.path(), &post.id).unwrap();
assert_eq!(discarded.status, PostStatus::Archived);
assert_eq!(discarded.content, None);
}
#[test]
fn discard_works_after_deployed_search_index_schema_is_repaired() {
let (db, dir) = setup();