From 89a07c885a22c050df23ff78f66e56e9d925f894 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 21 Jul 2026 22:04:03 +0200 Subject: [PATCH] Preserve file-only content when archiving. --- README.md | 2 +- crates/bds-core/src/engine/metadata_diff.rs | 54 ++++++++++- crates/bds-core/src/engine/post.rs | 5 +- crates/bds-core/tests/m4_generation_engine.rs | 13 +++ crates/bds-server/src/host.rs | 7 +- crates/bds-ui/src/app.rs | 76 +++++++++++++++ crates/bds-ui/src/app/editor_handlers.rs | 7 ++ crates/bds-ui/src/views/post_editor.rs | 94 +++++++++---------- locales/ui/de.ftl | 2 + locales/ui/en.ftl | 2 + locales/ui/es.ftl | 2 + locales/ui/fr.ftl | 2 + locales/ui/it.ftl | 2 + specs/post.allium | 3 + 14 files changed, 219 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 1fe1262..32b904d 100644 --- a/README.md +++ b/README.md @@ -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, explicit unarchive, in-place published-frontmatter updates, metadata, tags, categories, links, media, and batch gallery-image import. +- Post and translation authoring with change-aware draft/published/archive lifecycle, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, links, 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. diff --git a/crates/bds-core/src/engine/metadata_diff.rs b/crates/bds-core/src/engine/metadata_diff.rs index d4d06e8..87c9a4d 100644 --- a/crates/bds-core/src/engine/metadata_diff.rs +++ b/crates/bds-core/src/engine/metadata_diff.rs @@ -1035,7 +1035,7 @@ mod tests { use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::queries::script::insert_script; use crate::db::queries::template::insert_template; - use crate::engine::post::{create_post, publish_post}; + use crate::engine::post::{archive_post, create_post, publish_post}; use crate::model::{ Media, Post, PostStatus, ProjectMetadata, Script, ScriptKind, ScriptStatus, Template, TemplateKind, TemplateStatus, @@ -1095,6 +1095,58 @@ mod tests { assert!(report.orphans.is_empty(), "orphans: {:?}", report.orphans); } + #[test] + fn archived_published_post_reports_only_bds2_frontmatter_drift() { + let (db, dir) = setup(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Archived Post", + Some("body text"), + vec!["rust".into()], + vec!["tech".into()], + Some("Alice"), + Some("en"), + None, + ) + .unwrap(); + let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); + let path = dir.path().join(&published.file_path); + let original_file = fs::read(&path).unwrap(); + archive_post(db.conn(), dir.path(), &post.id).unwrap(); + + let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); + + assert_eq!(fs::read(path).unwrap(), original_file); + assert!( + report + .errors + .iter() + .all(|error| !error.starts_with("post ")), + "post diff errors: {:?}", + report.errors + ); + assert!(report.orphans.is_empty()); + let diff = report + .diffs + .iter() + .find(|diff| diff.entity_type == "post" && diff.entity_id == post.id) + .expect("archived database status must differ from untouched published file"); + assert!(diff.fields.iter().any(|field| { + field.field_name == "status" + && field.db_value == "archived" + && field.file_value == "published" + })); + assert!( + diff.fields + .iter() + .all(|field| matches!(field.field_name.as_str(), "status" | "updatedAt")), + "unexpected archived-post drift: {:?}", + diff.fields + ); + } + #[test] fn detects_title_drift_in_post() { let (db, dir) = setup(); diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index 2813551..96a9999 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -2375,12 +2375,15 @@ mod tests { None, ) .unwrap(); - publish_post(db.conn(), dir.path(), &post.id).unwrap(); + let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); + let post_path = dir.path().join(&published.file_path); + let published_file = fs::read(&post_path).unwrap(); archive_post(db.conn(), dir.path(), &post.id).unwrap(); let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap(); assert_eq!(from_db.status, PostStatus::Archived); assert_eq!(from_db.content, None); + assert_eq!(fs::read(post_path).unwrap(), published_file); } #[test] diff --git a/crates/bds-core/tests/m4_generation_engine.rs b/crates/bds-core/tests/m4_generation_engine.rs index f96f206..c31e689 100644 --- a/crates/bds-core/tests/m4_generation_engine.rs +++ b/crates/bds-core/tests/m4_generation_engine.rs @@ -126,6 +126,19 @@ fn reopened_draft_generation_uses_last_published_file() { assert_eq!(source.body_markdown, "Published body"); } +#[test] +fn archived_post_with_published_file_is_not_a_generation_source() { + let (_db, dir) = setup(); + let mut post = make_post("archived", 1_710_000_000_000); + write_published_snapshot(&dir, &mut post, "Published body"); + post.status = PostStatus::Archived; + post.content = None; + + let source = load_published_post_source(dir.path(), post).unwrap(); + + assert!(source.is_none()); +} + #[test] fn validation_keeps_reopened_draft_published_snapshots() { let (db, dir) = setup(); diff --git a/crates/bds-server/src/host.rs b/crates/bds-server/src/host.rs index 55a5f86..0245335 100644 --- a/crates/bds-server/src/host.rs +++ b/crates/bds-server/src/host.rs @@ -598,7 +598,12 @@ mod tests { update.as_slice(), [ServerMessage::Tasks { tasks, .. }] if tasks[0].progress == Some(0.5) )); - assert!(session.pending().is_empty()); + assert!( + session + .pending() + .iter() + .all(|message| !matches!(message, ServerMessage::Tasks { .. })) + ); } #[test] diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 40aac89..26ef8b9 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -6896,12 +6896,44 @@ impl BdsApp { tab.is_dirty = false; } self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.unarchived")); + return self.refresh_sidebar_posts(); } Err(error) => self.notify_operation_failed("editor.unarchive", error), } Task::none() } + fn archive_post_editor(&mut self, post_id: &str) -> Task { + if let Err(error) = self.persist_post_editor_state(post_id) { + self.notify_operation_failed("editor.archive", error); + return Task::none(); + } + let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else { + return Task::none(); + }; + if let Err(error) = engine::post::archive_post(db.conn(), data_dir, post_id) { + self.notify_operation_failed("editor.archive", error); + return Task::none(); + } + match bds_core::db::queries::post::get_post_by_id(db.conn(), post_id) { + Ok(post) => { + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.status = post.status; + editor.updated_at = post.updated_at; + editor.is_dirty = false; + editor.last_edit_at_ms = 0; + } + if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post_id) { + tab.is_dirty = false; + } + self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.archived")); + return self.refresh_sidebar_posts(); + } + Err(error) => self.notify_operation_failed("editor.archive", error), + } + Task::none() + } + fn delete_media_editor(&mut self, media_id: &str) -> Task { let Some(db) = &self.db else { return Task::none(); @@ -10910,6 +10942,50 @@ mod tests { })); } + #[test] + fn post_editor_archive_action_keeps_published_body_file_only() { + let (db, project, tmp) = setup(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Published", + Some("File body"), + Vec::new(), + Vec::new(), + None, + Some("en"), + None, + ) + .unwrap(); + let published = post::publish_post(db.conn(), tmp.path(), &created.id).unwrap(); + let path = tmp.path().join(&published.file_path); + let original_file = std::fs::read(&path).unwrap(); + let mut app = make_app(db, project, &tmp); + open_post_editor(&mut app, &published); + app.post_editors + .get_mut(&created.id) + .unwrap() + .quick_actions_open = true; + + let _ = app.handle_post_editor_msg(PostEditorMsg::Archive); + + let from_db = bds_core::db::queries::post::get_post_by_id( + app.db.as_ref().unwrap().conn(), + &created.id, + ) + .unwrap(); + assert_eq!(from_db.status, PostStatus::Archived); + assert_eq!(from_db.content, None); + assert_eq!(std::fs::read(path).unwrap(), original_file); + assert_eq!(app.post_editors[&created.id].status, PostStatus::Archived); + assert!(!app.post_editors[&created.id].quick_actions_open); + assert!(app.toasts.iter().any(|toast| { + toast.level == ToastLevel::Success + && toast.message == t(UiLocale::En, "editor.archived") + })); + } + #[test] fn preview_persist_bypasses_fts_for_translation_updates() { let (db, project, tmp) = setup(); diff --git a/crates/bds-ui/src/app/editor_handlers.rs b/crates/bds-ui/src/app/editor_handlers.rs index 7d7743a..66dfbef 100644 --- a/crates/bds-ui/src/app/editor_handlers.rs +++ b/crates/bds-ui/src/app/editor_handlers.rs @@ -21,6 +21,7 @@ impl BdsApp { }, Save(String), Publish(String), + Archive(String), Unarchive(String), Discard(String), ShowDelete { @@ -209,7 +210,12 @@ impl BdsApp { PostEditorMsg::Publish => { deferred = DeferredPostAction::Publish(tab_id.clone()); } + PostEditorMsg::Archive => { + state.quick_actions_open = false; + deferred = DeferredPostAction::Archive(tab_id.clone()); + } PostEditorMsg::Unarchive => { + state.quick_actions_open = false; deferred = DeferredPostAction::Unarchive(tab_id.clone()); } PostEditorMsg::Discard => { @@ -327,6 +333,7 @@ impl BdsApp { } => self.translate_post_to(&post_id, &target_language), DeferredPostAction::Save(tab_id) => self.save_post_editor(&tab_id, true), DeferredPostAction::Publish(tab_id) => self.publish_post_editor(&tab_id), + DeferredPostAction::Archive(tab_id) => self.archive_post_editor(&tab_id), DeferredPostAction::Unarchive(tab_id) => self.unarchive_post_editor(&tab_id), DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id), DeferredPostAction::ShowDelete { tab_id, name } => { diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index bd295d8..9957484 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -377,6 +377,7 @@ pub enum PostEditorMsg { RemoveCategory(String), Save, Publish, + Archive, Unarchive, Discard, Delete, @@ -440,44 +441,54 @@ pub fn view<'a>( .into() }); - let quick_actions_menu: Element<'a, Message> = container( - column![ - quick_actions_busy.unwrap_or_else(|| quick_action_item( + let mut quick_action_items = vec![ + quick_actions_busy.unwrap_or_else(|| { + quick_action_item( locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi, - ai_enabled && state.ai_activity.is_none() - )), - quick_action_item( - locale, - t(locale, "editor.suggestTaxonomy"), - PostEditorMsg::AnalyzeTaxonomy, - ai_enabled && state.ai_activity.is_none() - ), - quick_action_item( - locale, - t(locale, "editor.translate"), - PostEditorMsg::Translate, - ai_enabled && state.ai_activity.is_none() - ), - quick_action_item( - locale, - t(locale, "editor.detectLanguage"), - PostEditorMsg::DetectLanguage, - ai_enabled && state.ai_activity.is_none() - ), - quick_action_item( - locale, - t(locale, "editor.addGalleryImages"), - PostEditorMsg::AddGalleryImages, - true - ), - ] - .spacing(4), - ) - .padding(8) - .style(status_bar::dropdown_bg) - .into(); + ai_enabled && state.ai_activity.is_none(), + ) + }), + quick_action_item( + locale, + t(locale, "editor.suggestTaxonomy"), + PostEditorMsg::AnalyzeTaxonomy, + ai_enabled && state.ai_activity.is_none(), + ), + quick_action_item( + locale, + t(locale, "editor.translate"), + PostEditorMsg::Translate, + ai_enabled && state.ai_activity.is_none(), + ), + quick_action_item( + locale, + t(locale, "editor.detectLanguage"), + PostEditorMsg::DetectLanguage, + ai_enabled && state.ai_activity.is_none(), + ), + quick_action_item( + locale, + t(locale, "editor.addGalleryImages"), + PostEditorMsg::AddGalleryImages, + true, + ), + ]; + if !on_translation { + let (label, message) = match state.status { + PostStatus::Archived => (t(locale, "editor.unarchive"), PostEditorMsg::Unarchive), + PostStatus::Draft | PostStatus::Published => { + (t(locale, "editor.archive"), PostEditorMsg::Archive) + } + }; + quick_action_items.push(quick_action_item(locale, label, message, true)); + } + let quick_actions_menu: Element<'a, Message> = + container(iced::widget::Column::with_children(quick_action_items).spacing(4)) + .padding(8) + .style(status_bar::dropdown_bg) + .into(); let quick_actions: Element<'a, Message> = popover::popover( quick_actions_button, quick_actions_menu, @@ -512,19 +523,6 @@ pub fn view<'a>( .into(), ); } - if !on_translation && state.status == PostStatus::Archived { - header_action_items.push( - button( - text(t(locale, "editor.unarchive")) - .size(13) - .shaping(Shaping::Advanced), - ) - .on_press(Message::PostEditor(PostEditorMsg::Unarchive)) - .style(inputs::secondary_button) - .padding([6, 16]) - .into(), - ); - } if !on_translation && state.status == PostStatus::Draft && state.published_at.is_some() { header_action_items.push( button( diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 5a71dc4..cc01e11 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -419,6 +419,8 @@ editor-translate = Übersetzen editor-templateSlug = Vorlage editor-doNotTranslate = Nicht übersetzen editor-publish = Veröffentlichen +editor-archive = Archivieren +editor-archived = Ins Archiv verschoben. editor-unarchive = Wiederherstellen editor-unarchived = Als Entwurf wiederhergestellt. editor-statusDraft = Entwurf diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 664e84f..5eb59a8 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -404,6 +404,8 @@ editor-translate = Translate editor-templateSlug = Template editor-doNotTranslate = Do Not Translate editor-publish = Publish +editor-archive = Archive +editor-archived = Moved to archive. editor-unarchive = Unarchive editor-unarchived = Restored to draft. editor-statusDraft = Draft diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index d0ef3e3..a115317 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -419,6 +419,8 @@ editor-translate = Traducir editor-templateSlug = Plantilla editor-doNotTranslate = No traducir editor-publish = Publicar +editor-archive = Archivar +editor-archived = Movido al archivo. editor-unarchive = Desarchivar editor-unarchived = Restaurado como borrador. editor-statusDraft = Borrador diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index f585678..938ee66 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -419,6 +419,8 @@ editor-translate = Traduire editor-templateSlug = Modèle editor-doNotTranslate = Ne pas traduire editor-publish = Publier +editor-archive = Archiver +editor-archived = Déplacé dans les archives. editor-unarchive = Désarchiver editor-unarchived = Restauré comme brouillon. editor-statusDraft = Brouillon diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 260e13d..31eb66d 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -419,6 +419,8 @@ editor-translate = Traduci editor-templateSlug = Modello editor-doNotTranslate = Non tradurre editor-publish = Pubblica +editor-archive = Archivia +editor-archived = Spostato in archivio. editor-unarchive = Ripristina editor-unarchived = Ripristinato come bozza. editor-statusDraft = Bozza diff --git a/specs/post.allium b/specs/post.allium index 0d0a7b7..0828f5e 100644 --- a/specs/post.allium +++ b/specs/post.allium @@ -237,6 +237,9 @@ rule ArchivePost { when: ArchivePostRequested(post) requires: post.status = draft or post.status = published ensures: post.status = archived + -- Archiving changes only status and updatedAt. A published post keeps its + -- body solely in the existing file: content remains null and the file is + -- not rewritten. } rule UnarchivePost {