From 3c26e2e3eb572e240a6f5cdfe7852d973297494c Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:50:55 +0200 Subject: [PATCH] Apply Redactions replaces the recovery snapshot at once (#6454) --- crates/engine/src/document.rs | 3 +- crates/engine/src/session/save.rs | 9 ++++ crates/ui-egui/src/dialogs.rs | 9 ++++ crates/ui-egui/src/persist/recovery.rs | 11 +++++ crates/ui-egui/tests/it/recovery.rs | 59 +++++++++++++++++++++++++- 5 files changed, 89 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 22667377c3..08654f6752 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -32,7 +32,8 @@ pub struct Document { pub dirty: bool, /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. pub(crate) generation: u64, - /// The generation last handed out by `autosave_snapshots`. + /// The generation last handed out by `autosave_snapshots`, or another one when storing that + /// snapshot failed (`autosave_failed`), so it is due again. pub(crate) snapshot_generation: u64, /// Why the document cannot be edited (e.g. encryption), if so. pub read_only_reason: Option, diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index 34f4d4405d..a727ce6329 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -217,6 +217,15 @@ impl Session { out } + /// The snapshot [`Self::autosave_snapshots`] handed out for `id` could not be stored: the + /// document is due again, so the next autosave tries it again. + pub fn autosave_failed(&mut self, id: DocId) { + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + // Any generation but the current one leaves it due. + d.snapshot_generation = d.generation.wrapping_sub(1); + } + } + /// Mark a document opened from a recovery file: it has unsaved changes and belongs at /// `path` (where Save writes), as when the session ended. pub fn mark_recovered(&mut self, id: DocId, path: Option) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c4a49acd67..982878cb43 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1846,6 +1846,9 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { let which: Vec = app.hidden_draft.found.iter().filter(|f| f.2 && f.1 > 0).map(|f| f.0).collect(); let n: usize = app.hidden_draft.found.iter().filter(|f| f.2).map(|f| f.1).sum(); if app.apply_edit(Edit::RemoveHidden { which }) { + if let Some((_, id)) = app.active_ids() { + app.renew_recovery(id); + } if n == 1 { app.notify_tr("Removed 1 hidden item. Save to remove them from the file."); } else { @@ -1854,11 +1857,17 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { } } Some(Dialog::Sanitize) if app.apply_edit(Edit::Sanitize) => { + if let Some((_, id)) = app.active_ids() { + app.renew_recovery(id); + } app.notify_tr("Document sanitized. Save to finish: saving rewrites the whole file."); } Some(Dialog::RedactApply) => { let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); if app.apply_edit(Edit::ApplyRedactions { pages: None }) { + if let Some((_, id)) = app.active_ids() { + app.renew_recovery(id); + } if marks == 1 { app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file."); } else { diff --git a/crates/ui-egui/src/persist/recovery.rs b/crates/ui-egui/src/persist/recovery.rs index f217781c07..34b6bdf76a 100644 --- a/crates/ui-egui/src/persist/recovery.rs +++ b/crates/ui-egui/src/persist/recovery.rs @@ -320,6 +320,9 @@ impl PdfCraftApp { RecoveryMeta { key, name: snap.name, path: snap.path, saved_at: now_secs(), encrypted: snap.encrypted, instance: store.instance() }; if let Err(e) = store.write(&meta, &snap.bytes) { log::warn!("autosave failed: {e}"); + // Still due: the next autosave writes it. After `renew_recovery` there is no + // older snapshot to fall back on. + self.session.autosave_failed(snap.doc); } } } @@ -331,6 +334,14 @@ impl PdfCraftApp { } } + /// Replace a document's recovery snapshot with its current state now. After an edit that takes + /// content out (Apply Redactions, Sanitize, Remove Hidden Information), the last snapshot still + /// holds what went, until the next autosave, and for good if the app is killed before then. + pub(crate) fn renew_recovery(&mut self, doc: pdfcraft_engine::DocId) { + self.forget_recovery(doc); + self.autosave_now(); + } + /// Autosave when the interval has passed (called by `logic`, which also runs while hidden). /// /// The deadline is monotonic (`web_time::Instant`): egui's input clock does not advance diff --git a/crates/ui-egui/tests/it/recovery.rs b/crates/ui-egui/tests/it/recovery.rs index 2d8258eeda..93ea045b6e 100644 --- a/crates/ui-egui/tests/it/recovery.rs +++ b/crates/ui-egui/tests/it/recovery.rs @@ -8,7 +8,7 @@ use egui::{Key, Modifiers}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_engine::Edit; -use pdfcraft_ui_egui::{PdfCraftApp, RecoveryStore}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, RecoveryStore}; fn fixture(n: usize) -> Vec { let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; @@ -131,6 +131,63 @@ fn saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents let _ = std::fs::remove_file(out); } +/// Whether any recovery snapshot in `s` holds `text`, in its bytes or in a decoded stream. +fn snapshot_holds(s: &RecoveryStore, text: &[u8]) -> bool { + let found = |data: &[u8]| data.windows(text.len()).any(|w| w == text); + s.list().iter().filter_map(|m| s.read(&m.key).ok()).any(|bytes| { + found(&bytes) + || pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).is_ok_and(|doc| { + doc.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| found(&d)))) + }) + }) +} + +/// Applying redactions replaces the recovery snapshot at once: the one autosaved before still holds +/// what the marks covered, until the next autosave, and for good after a crash. +#[test] +fn applying_redactions_replaces_the_recovery_snapshot_at_once() { + let s = store("redact"); + let mut h = harness(s.clone()); + h.state_mut().open_bytes("a.pdf", None, fixture(1)).unwrap(); + assert!(h.state_mut().apply_edit(Edit::RotatePages { pages: vec![0], degrees: 90 })); + h.state_mut().autosave_now(); + assert!(snapshot_holds(&s, b"Confidential"), "the autosave before the redaction holds the page's text"); + // Redact pages: the current page, then apply. + assert!(h.state_mut().execute("redact.mark")); + h.run_steps(2); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + h.state_mut().dialog = Some(Dialog::RedactApply); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert!(!snapshot_holds(&s, b"Confidential"), "a recovery snapshot still holds the redacted text"); + assert_eq!(s.list().len(), 1, "the redacted document is autosaved in its place"); + s.clear(); +} + +/// An autosave that could not be written leaves the document due, so the next one writes it. +/// Renewing the snapshot after a redaction removes the old one first, so nothing else is left. +#[test] +fn a_failed_autosave_is_written_by_the_next_one() { + let s = store("retry"); + // A file where the folder belongs: the folder can't be made, so nothing can be written. + std::fs::write(s.dir(), b"").unwrap(); + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.enable_recovery(s.clone()); + app.open_bytes("a.pdf", None, fixture(1)).unwrap(); + assert!(app.apply_edit(Edit::RotatePages { pages: vec![0], degrees: 90 })); + app.autosave_now(); + assert!(s.list().is_empty(), "the folder can't be made"); + std::fs::remove_file(s.dir()).unwrap(); + app.autosave_now(); + assert_eq!(s.list().len(), 1, "the snapshot that failed is written by the next autosave"); + s.clear(); +} + #[test] fn quitting_cleanly_leaves_nothing_behind() { let s = store("quit");