Preserve file-only content when archiving.

This commit is contained in:
2026-07-21 22:04:03 +02:00
parent 1c04087471
commit 89a07c885a
14 changed files with 219 additions and 52 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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