diff --git a/README.md b/README.md index de17025..1fe1262 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 lifecycle, 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, explicit unarchive, 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/post.rs b/crates/bds-core/src/engine/post.rs index cdf307b..2813551 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -199,17 +199,6 @@ pub fn update_post( post.content = published_body; } post.status = PostStatus::Draft; - } else if post.status == PostStatus::Archived { - if post.content.is_none() && !post.file_path.is_empty() { - let abs_path = data_dir.join(&post.file_path); - if abs_path.exists() - && let Ok(file_content) = fs::read_to_string(&abs_path) - && let Ok((_fm, body)) = read_post_file(&file_content) - { - post.content = Some(body); - } - } - post.status = PostStatus::Draft; } post.updated_at = now_unix_ms(); @@ -389,26 +378,47 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine "post is already archived".to_string(), )); } - // Reload content from filesystem if transitioning from published (content is NULL) - if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty() - { - let abs_path = data_dir.join(&post.file_path); - if abs_path.exists() - && let Ok(file_content) = fs::read_to_string(&abs_path) - && let Ok((_fm, body)) = read_post_file(&file_content) - { - post.content = Some(body); - qp::update_post(conn, &post)?; - } - } let now = now_unix_ms(); - qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?; post.status = PostStatus::Archived; + post.updated_at = now; + qp::update_post(conn, &post)?; + fts_index_post(conn, data_dir, &post)?; emit_post(&post, NotificationAction::Updated); crate::engine::embedding::sync_post_best_effort(conn, data_dir, &post); Ok(()) } +/// Restore an archived post to an editable draft. +pub fn unarchive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult { + let mut post = qp::get_post_by_id(conn, post_id)?; + if post.status != PostStatus::Archived { + return Err(EngineError::Conflict( + "cannot unarchive a post that is not archived".to_string(), + )); + } + + post.content = Some(restore_content_for_unarchive(data_dir, &post)); + post.status = PostStatus::Draft; + post.updated_at = now_unix_ms(); + qp::update_post(conn, &post)?; + fts_index_post(conn, data_dir, &post)?; + emit_post(&post, NotificationAction::Updated); + crate::engine::embedding::sync_post_best_effort(conn, data_dir, &post); + Ok(post) +} + +fn restore_content_for_unarchive(data_dir: &Path, post: &Post) -> String { + post.content.clone().unwrap_or_else(|| { + if post.file_path.is_empty() { + return String::new(); + } + fs::read_to_string(data_dir.join(&post.file_path)) + .ok() + .and_then(|raw| read_post_file(&raw).ok().map(|(_, body)| body)) + .unwrap_or_default() + }) +} + /// Discard unpublished draft changes and restore the published version. pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult { let mut post = qp::get_post_by_id(conn, post_id)?; @@ -2370,6 +2380,65 @@ mod tests { 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); + } + + #[test] + fn update_archived_post_keeps_archived_status() { + let (db, dir) = setup(); + let post = create_post( + db.conn(), + dir.path(), + "p1", + "Archived", + Some("draft body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + archive_post(db.conn(), dir.path(), &post.id).unwrap(); + + let updated = update_post( + db.conn(), + dir.path(), + &post.id, + Some("Changed while archived"), + None, + None, + Some("changed body"), + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + + assert_eq!(updated.status, PostStatus::Archived); + assert_eq!(updated.title, "Changed while archived"); + assert_eq!(updated.content.as_deref(), Some("changed body")); + } + + #[test] + fn unarchive_published_post_restores_body_from_file() { + let (db, dir) = setup(); + let post = create_published_post(&db, &dir, "Published", "file body"); + archive_post(db.conn(), dir.path(), &post.id).unwrap(); + let archived = qp::get_post_by_id(db.conn(), &post.id).unwrap(); + assert_eq!(archived.content, None); + + let unarchived = unarchive_post(db.conn(), dir.path(), &post.id).unwrap(); + + assert_eq!(unarchived.status, PostStatus::Draft); + assert_eq!(unarchived.content.as_deref(), Some("file body")); + assert!(unarchived.updated_at >= archived.updated_at); + let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap(); + assert_eq!(from_db.status, PostStatus::Draft); + assert_eq!(from_db.content.as_deref(), Some("file body")); } #[test] diff --git a/crates/bds-core/tests/domain_events.rs b/crates/bds-core/tests/domain_events.rs index 2da4100..a8a52b5 100644 --- a/crates/bds-core/tests/domain_events.rs +++ b/crates/bds-core/tests/domain_events.rs @@ -335,6 +335,20 @@ fn representative_shared_mutations_emit_exactly_one_typed_event_each() { &post.id, NotificationAction::Updated, ); + bds_core::engine::post::archive_post(db.conn(), dir.path(), &post.id).unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Post, + &post.id, + NotificationAction::Updated, + ); + bds_core::engine::post::unarchive_post(db.conn(), dir.path(), &post.id).unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Post, + &post.id, + NotificationAction::Updated, + ); bds_core::engine::media::update_media( db.conn(), diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index b593d7c..40aac89 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -628,7 +628,7 @@ fn persist_post_editor_preview_state_impl( Some(state.template_slug.clone()) }; post.do_not_translate = state.do_not_translate; - if matches!(post.status, PostStatus::Published | PostStatus::Archived) { + if post.status == PostStatus::Published { post.status = PostStatus::Draft; } post.updated_at = bds_core::util::now_unix_ms(); @@ -6875,6 +6875,33 @@ impl BdsApp { Task::none() } + fn unarchive_post_editor(&mut self, post_id: &str) -> Task { + if let Err(error) = self.persist_post_editor_state(post_id) { + self.notify_operation_failed("editor.unarchive", error); + return Task::none(); + } + let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else { + return Task::none(); + }; + match engine::post::unarchive_post(db.conn(), data_dir, post_id) { + Ok(post) => { + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.content = post.content.clone().unwrap_or_default(); + 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.unarchived")); + } + Err(error) => self.notify_operation_failed("editor.unarchive", error), + } + Task::none() + } + fn delete_media_editor(&mut self, media_id: &str) -> Task { let Some(db) = &self.db else { return Task::none(); @@ -10803,6 +10830,86 @@ mod tests { } } + #[test] + fn preview_persist_keeps_archived_canonical_post_archived() { + let (db, project, tmp) = setup(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Archived", + Some("Body"), + Vec::new(), + vec!["article".to_string()], + None, + Some("en"), + None, + ) + .unwrap(); + post::archive_post(db.conn(), tmp.path(), &created.id).unwrap(); + let archived = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); + let mut editor = PostEditorState::from_post( + &archived, + "preview", + &["en".to_string()], + &[], + Vec::new(), + Vec::new(), + Vec::new(), + ); + editor.title = "Changed while archived".to_string(); + + let result = persist_post_editor_preview_state_impl(&db, &editor).unwrap(); + + match result { + PersistedPostState::Canonical(post) => { + assert_eq!(post.status, PostStatus::Archived); + assert_eq!(post.title, "Changed while archived"); + } + PersistedPostState::Translation(_) => panic!("expected canonical post save"), + } + } + + #[test] + fn post_editor_unarchive_action_restores_draft_and_body() { + let (db, project, tmp) = setup(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Archived", + Some("File body"), + Vec::new(), + Vec::new(), + None, + Some("en"), + None, + ) + .unwrap(); + post::publish_post(db.conn(), tmp.path(), &created.id).unwrap(); + post::archive_post(db.conn(), tmp.path(), &created.id).unwrap(); + let archived = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); + assert_eq!(archived.content, None); + + let mut app = make_app(db, project, &tmp); + open_post_editor(&mut app, &archived); + let _ = app.handle_post_editor_msg(PostEditorMsg::Unarchive); + + 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::Draft); + assert_eq!(from_db.content.as_deref(), Some("File body")); + assert_eq!(app.post_editors[&created.id].status, PostStatus::Draft); + assert_eq!(app.post_editors[&created.id].content, "File body"); + assert!(app.toasts.iter().any(|toast| { + toast.level == ToastLevel::Success + && toast.message == t(UiLocale::En, "editor.unarchived") + })); + } + #[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 692921c..7d7743a 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), + Unarchive(String), Discard(String), ShowDelete { tab_id: String, @@ -208,6 +209,9 @@ impl BdsApp { PostEditorMsg::Publish => { deferred = DeferredPostAction::Publish(tab_id.clone()); } + PostEditorMsg::Unarchive => { + deferred = DeferredPostAction::Unarchive(tab_id.clone()); + } PostEditorMsg::Discard => { deferred = DeferredPostAction::Discard(tab_id.clone()); } @@ -323,6 +327,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::Unarchive(tab_id) => self.unarchive_post_editor(&tab_id), DeferredPostAction::Discard(tab_id) => self.discard_post_editor(&tab_id), DeferredPostAction::ShowDelete { tab_id, name } => { Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete { diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 9e95ad4..bd295d8 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, + Unarchive, Discard, Delete, InsertLink, @@ -486,7 +487,7 @@ pub fn view<'a>( .into(); let mut header_action_items: Vec> = vec![ - status_badge(&state.status), + status_badge(locale, &state.status), quick_actions, button( text(t(locale, "common.save")) @@ -511,6 +512,19 @@ 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( @@ -1235,11 +1249,20 @@ fn content_actions_visible(mode: &str) -> bool { mode == "markdown" } -fn status_badge<'a>(status: &PostStatus) -> Element<'a, Message> { +fn status_badge<'a>(locale: UiLocale, status: &PostStatus) -> Element<'a, Message> { let (label, color) = match status { - PostStatus::Draft => ("Draft", Color::from_rgb(0.8, 0.7, 0.2)), - PostStatus::Published => ("Published", Color::from_rgb(0.2, 0.7, 0.3)), - PostStatus::Archived => ("Archived", Color::from_rgb(0.5, 0.5, 0.5)), + PostStatus::Draft => ( + t(locale, "editor.statusDraft"), + Color::from_rgb(0.8, 0.7, 0.2), + ), + PostStatus::Published => ( + t(locale, "editor.statusPublished"), + Color::from_rgb(0.2, 0.7, 0.3), + ), + PostStatus::Archived => ( + t(locale, "editor.statusArchived"), + Color::from_rgb(0.5, 0.5, 0.5), + ), }; container( text(label) diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 2b4293e..5a71dc4 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -419,6 +419,11 @@ editor-translate = Übersetzen editor-templateSlug = Vorlage editor-doNotTranslate = Nicht übersetzen editor-publish = Veröffentlichen +editor-unarchive = Wiederherstellen +editor-unarchived = Als Entwurf wiederhergestellt. +editor-statusDraft = Entwurf +editor-statusPublished = Veröffentlicht +editor-statusArchived = Archiviert editor-discard = Verwerfen editor-validate = Validieren editor-run = Ausführen diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 109601f..664e84f 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -404,6 +404,11 @@ editor-translate = Translate editor-templateSlug = Template editor-doNotTranslate = Do Not Translate editor-publish = Publish +editor-unarchive = Unarchive +editor-unarchived = Restored to draft. +editor-statusDraft = Draft +editor-statusPublished = Published +editor-statusArchived = Archived editor-discard = Discard editor-validate = Validate editor-run = Run diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index c1c37e2..d0ef3e3 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -419,6 +419,11 @@ editor-translate = Traducir editor-templateSlug = Plantilla editor-doNotTranslate = No traducir editor-publish = Publicar +editor-unarchive = Desarchivar +editor-unarchived = Restaurado como borrador. +editor-statusDraft = Borrador +editor-statusPublished = Publicado +editor-statusArchived = Archivado editor-discard = Descartar editor-validate = Validar editor-run = Ejecutar diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index d38b19c..f585678 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -419,6 +419,11 @@ editor-translate = Traduire editor-templateSlug = Modèle editor-doNotTranslate = Ne pas traduire editor-publish = Publier +editor-unarchive = Désarchiver +editor-unarchived = Restauré comme brouillon. +editor-statusDraft = Brouillon +editor-statusPublished = Publié +editor-statusArchived = Archivé editor-discard = Annuler les modifications editor-validate = Valider editor-run = Exécuter diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index f7eca45..260e13d 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -419,6 +419,11 @@ editor-translate = Traduci editor-templateSlug = Modello editor-doNotTranslate = Non tradurre editor-publish = Pubblica +editor-unarchive = Ripristina +editor-unarchived = Ripristinato come bozza. +editor-statusDraft = Bozza +editor-statusPublished = Pubblicato +editor-statusArchived = Archiviato editor-discard = Scarta editor-validate = Valida editor-run = Esegui diff --git a/specs/post.allium b/specs/post.allium index 9078d77..0d0a7b7 100644 --- a/specs/post.allium +++ b/specs/post.allium @@ -56,6 +56,7 @@ surface PostControlSurface { PublishPostRequested(post) DeletePostRequested(post) ArchivePostRequested(post) + UnarchivePostRequested(post) DiscardPostChangesRequested(post) SyncPostFromFileRequested(post) ImportOrphanPostFileRequested(project, relative_path) @@ -238,6 +239,15 @@ rule ArchivePost { ensures: post.status = archived } +rule UnarchivePost { + when: UnarchivePostRequested(post) + requires: post.status = archived + ensures: post.status = draft + ensures: post.content = archived_database_content(post) ?? file_body(post.file_path) ?? "" + ensures: SearchIndexUpdated(post) + ensures: EmbeddingUpdated(post) +} + rule SyncPostFromFile { when: SyncPostFromFileRequested(post) requires: post.file_path != ""