Add explicit post unarchive.

This commit is contained in:
2026-07-21 21:49:06 +02:00
parent 1e32c7e945
commit 1c04087471
12 changed files with 284 additions and 31 deletions

View File

@@ -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<Post> {
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<Post> {
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]

View File

@@ -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(),

View File

@@ -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<Message> {
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<Message> {
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();

View File

@@ -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 {

View File

@@ -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<Element<'a, Message>> = 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)