diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index a793487d9f..a806d04283 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -3,7 +3,7 @@ use pdfcraft_engine::{ Edit, HIDDEN, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator, - RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, + RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, redacted_file_name, }; use serde_json::{Value, json}; @@ -200,6 +200,8 @@ impl Automation { let after = self.doc(a)?.redaction_marks(); out["applied"] = json!(before - after); out["marks_pending"] = json!(after); + // The name to save the redacted copy under, as Save does in the app. + out["save_as"] = json!(redacted_file_name(&self.doc(a)?.name)); Ok(out) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 83690c1ba7..c100452fd5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -678,7 +678,7 @@ pub fn tools() -> Vec { t( "redact_apply", "Apply redactions", - "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content.", + "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content. The result's save_as is the document's name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name that already ends in _Redacted is unchanged); pass it to doc_save as path to save under that name.", ) .destructive() .with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 55d0ae1728..660a418ab5 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5356,6 +5356,28 @@ fn redacting_through_tools() { assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn applying_redactions_proposes_the_name_with_redacted() { + let dir = workdir("redact-save-as"); + std::fs::write(dir.join("memo.txt"), "Call 555-123-4567 today").unwrap(); + let mut a = auto(&dir); + let made = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_save", json!({ "doc": made, "path": "contract.pdf" })); + let original = std::fs::read(dir.join("contract.pdf")).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "contract.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "phone" })); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!(r["save_as"], "contract_Redacted.pdf"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": r["save_as"] })); + assert_eq!(std::fs::read(dir.join("contract.pdf")).unwrap(), original, "the opened file is left as it was"); + let bytes = std::fs::read(dir.join("contract_Redacted.pdf")).unwrap(); + assert!(!bytes.windows(4).any(|w| w == b"4567"), "the copy has no trace of the number"); + // Redacting the redacted copy again proposes the same name, with one suffix. + let copy = ok(&mut a, "doc_open", json!({ "path": "contract_Redacted.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": copy, "find": "today" })); + assert_eq!(ok(&mut a, "redact_apply", json!({ "doc": copy }))["save_as"], "contract_Redacted.pdf"); +} + #[test] fn redacting_with_a_caller_regex_through_tools() { let dir = workdir("redact-regex"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0dc7c231cb..8923ed0c6d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -132,6 +132,7 @@ pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as Re pub use pdfcraft_redact::patterns::{ MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, }; +pub use pdfcraft_redact::redacted_file_name; pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; pub use pdfcraft_redact::user_regex::{UserRegex as RedactRegex, VALIDATORS as REDACT_VALIDATORS, Validator as RedactValidator}; pub use pdfcraft_sign as sign; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 12cd3f6232..55a263f98b 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -1009,3 +1009,15 @@ pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result String { + let cut = name.len().saturating_sub(4); + let stem = match (name.get(..cut), name.get(cut..)) { + (Some(stem), Some(ext)) if ext.eq_ignore_ascii_case(".pdf") => stem, + _ => name, + }; + if stem.ends_with("_Redacted") { format!("{stem}.pdf") } else { format!("{stem}_Redacted.pdf") } +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index d4d16920ef..af4c87d1e3 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2319,6 +2319,17 @@ fn clearing_marks_leaves_the_annotation_list_as_it_was() { assert_eq!((doc.get(page), doc.get(list)), before, "the page points at its array, which lists the link alone"); } +#[test] +fn redacted_copies_are_named_with_redacted_before_the_extension() { + assert_eq!(redacted_file_name("contract.pdf"), "contract_Redacted.pdf"); + assert_eq!(redacted_file_name("SCAN.PDF"), "SCAN_Redacted.pdf"); + assert_eq!(redacted_file_name("Report.Pdf"), "Report_Redacted.pdf"); + // A document without the extension (opened from bytes) gets it. + assert_eq!(redacted_file_name("Untitled"), "Untitled_Redacted.pdf"); + // Redacting a redacted copy again does not add a second suffix. + assert_eq!(redacted_file_name("contract_Redacted.pdf"), "contract_Redacted.pdf"); +} + #[test] fn unreadable_content_fails_closed() { let mut doc = one_page(b"", "", vec![]); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4ddb913985..f23c359020 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -715,6 +715,24 @@ pub struct DocView { /// Redact tool: the box being drawn, and a mark to add (page, quads) for the app to style. pub redact_drag: crate::redact_ui::AreaDrag, pub pending_redaction: Option<(usize, Vec<[f64; 8]>)>, + /// How many Apply redactions steps are in effect in this view. While it is above 0, Save + /// proposes the name with `_Redacted` ([`pdfcraft_engine::redacted_file_name`]), and while it is + /// above `redactions_downloaded`, Save asks where. + /// Undo of an Apply redactions step takes one off and Redo adds it back. Reload and a desktop + /// save set it to 0, and Revert sets it to `redactions_downloaded`; after a desktop save the document has the name it was saved + /// under. A web download keeps it, so later downloads carry the `_Redacted` name. + pub redactions_applied: usize, + /// How many Apply redactions steps saved into the document's file are undone in this view. + /// While it is above 0, Save asks where, so the restored content is not written over the + /// redacted file. Redo takes one off. Revert, Reload and a desktop save set it to 0. + pub redactions_undone_after_save: usize, + /// Set when Apply redactions runs while saved Apply redactions steps are undone: Redo can no + /// longer bring those steps back, so Save asks where until a desktop save or Revert clears it. + pub saved_redactions_cut_off: bool, + /// `redactions_applied` at the last web download, which Revert restores. While + /// `redactions_applied` is below it, a delivered step is undone and Save asks where. A desktop + /// save leaves it at 0. + pub redactions_downloaded: usize, /// A crop rectangle being dragged (Crop tool). pub crop_drag: crate::crop::CropDrag, /// Enhance ▸ Camera image: the quadrilateral being placed. @@ -1130,6 +1148,10 @@ impl DocView { links: Default::default(), redact_drag: None, pending_redaction: None, + redactions_applied: 0, + redactions_undone_after_save: 0, + saved_redactions_cut_off: false, + redactions_downloaded: 0, crop_drag: None, camera: None, camera_done: None, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1db3be1218..e36a3a59ed 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1885,6 +1885,15 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { 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(view) = app.active.and_then(|i| app.views.get_mut(i)) { + let delivered_undone = view.redactions_applied < view.redactions_downloaded; + view.redactions_applied = view.redactions_applied.saturating_add(1); + // A new step clears the redo history, so the undone saved steps can't come back. + if view.redactions_undone_after_save > 0 || delivered_undone { + view.redactions_undone_after_save = 0; + view.saved_redactions_cut_off = true; + } + } if let Some((_, id)) = app.active_ids() { app.renew_recovery(id); } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 46f4683c8a..40e5df96b9 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -20,7 +20,7 @@ pub use save::{SaveTarget, write_atomically}; #[cfg(test)] pub(crate) use save::{is_mail_temp_copy, save_dialog_folder}; #[cfg(test)] -use save::{write_atomically_syncing, write_atomically_with}; +use save::{unredacted_file_name, write_atomically_syncing, write_atomically_with}; /// Text typed into a new text box that isn't added to the page yet. fn new_text_typed(v: &crate::DocView) -> bool { @@ -380,6 +380,17 @@ impl PdfCraftApp { view.comments.selected = None; view.comments.history_stepped(); } + if label == (Edit::ApplyRedactions { pages: None }).label() { + let view = &mut self.views[i]; + let (applied, undone) = (view.redactions_applied, view.redactions_undone_after_save); + // Undo past the last save counts as undone; Redo first takes those back. + match (undo, applied, undone) { + (true, 0, _) => view.redactions_undone_after_save = undone.saturating_add(1), + (true, _, _) => view.redactions_applied = applied.saturating_sub(1), + (false, _, 0) => view.redactions_applied = applied.saturating_add(1), + (false, _, _) => view.redactions_undone_after_save = undone.saturating_sub(1), + } + } let label = crate::i18n::edit_label(&label); if undo { self.notify_fmt("Undid {label}", &[("label", &label)]); diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index c6d91b64e8..d891d4474e 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -94,6 +94,11 @@ impl PdfCraftApp { view.forms.committed = None; view.forms.unbaked.clear(); view.pending_edit = None; + // The redactions applied since the last save are discarded too. Those in the + // last web download stay, so later downloads keep the `_Redacted` name. + view.redactions_applied = view.redactions_downloaded; + view.redactions_undone_after_save = 0; + view.saved_redactions_cut_off = false; } self.notify_tr("Reverted to the last saved version"); } diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 35867559f6..fe9e0f8b81 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -50,6 +50,21 @@ impl PdfCraftApp { let Some(id) = self.views.get(index).map(|v| v.id) else { return false }; let Some(doc) = self.session.get(id) else { return false }; let (name, path) = (doc.name.clone(), doc.path.clone()); + // After Apply redactions, Save asks where and proposes the name with `_Redacted`. + // In the web build a delivered step stays counted, so only the steps applied since the + // last delivery make Save ask where. + let redacted = self.views.get(index).is_some_and(|v| v.redactions_applied > 0); + let not_delivered = self.views.get(index).is_some_and(|v| v.redactions_applied > v.redactions_downloaded); + let name = if redacted { pdfcraft_engine::redacted_file_name(&name) } else { name }; + // Undo past a saved Apply redactions brings back content that the file on disk has + // redacted: ask where instead of writing it over that file. In the web build a delivered + // step stays counted in `redactions_applied`, so fewer steps than were delivered means one + // was undone. + let unredacted = self.views.get(index).is_some_and(restores_saved_redactions); + // The restored content is not a redacted copy: propose the name without `_Redacted`, so + // the dialog's default is not the redacted file. + let name = if unredacted && !redacted { unredacted_file_name(&name) } else { name }; + let target = if not_delivered || unredacted { SaveTarget::As } else { target }; #[cfg(not(target_arch = "wasm32"))] { let doc_path = path.clone(); @@ -66,6 +81,14 @@ impl PdfCraftApp { let dialog = in_document_folder(dialog, doc_path.as_deref()); // The bytes are taken once the user has chosen, so edits made meanwhile are saved too. self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| { + // An Apply redactions or an Undo of one while the dialog was open makes the + // name it proposed wrong: ask again with the name for the current content. + let asked = (redacted, unredacted); + let changed = |v: &crate::DocView| v.id == id && (v.redactions_applied > 0, restores_saved_redactions(v)) != asked; + if let Some(index) = app.views.iter().position(changed) { + app.save_then(index, SaveTarget::As, after); + return; + } app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after)); }); return false; @@ -100,6 +123,9 @@ impl PdfCraftApp { && let Some(view) = self.views.get_mut(index) { view.document_changed(&doc.info); + // `redactions_applied` stays set: the document keeps its name here, so + // later downloads are named after the redacted copy too. + view.redactions_downloaded = view.redactions_applied; } // The host reports where the file went (and any failure) itself. if !to_host { @@ -206,6 +232,9 @@ impl PdfCraftApp { // Saving over a version that changed on disk keeps this one: nothing to ask. for view in self.views.iter_mut().filter(|v| v.id == id) { view.disk_changed = false; + view.redactions_applied = 0; + view.redactions_undone_after_save = 0; + view.saved_redactions_cut_off = false; } self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]); #[cfg(target_os = "macos")] @@ -244,6 +273,27 @@ pub(crate) fn is_mail_temp_copy(path: &str) -> bool { || p.contains("/library/containers/com.apple.mail/data/library/mail downloads/") } +/// Whether `view` holds content that a saved or delivered Apply redactions removed: a saved step +/// is undone, cannot be redone, or (in the web build) fewer steps are in effect than were +/// delivered. +fn restores_saved_redactions(view: &crate::DocView) -> bool { + view.redactions_undone_after_save > 0 || view.saved_redactions_cut_off || view.redactions_applied < view.redactions_downloaded +} + +/// `name` without the `_Redacted` that [`pdfcraft_engine::redacted_file_name`] adds before the +/// extension ("contract_Redacted.pdf" gives "contract.pdf"). Any other name is returned as it is. +pub(crate) fn unredacted_file_name(name: &str) -> String { + let cut = name.len().saturating_sub(4); + let (stem, ext) = match (name.get(..cut), name.get(cut..)) { + (Some(stem), Some(ext)) if ext.eq_ignore_ascii_case(".pdf") => (stem, ext), + _ => (name, ""), + }; + match stem.strip_suffix("_Redacted") { + Some(base) if !base.is_empty() => format!("{base}{ext}"), + _ => name.to_string(), + } +} + /// The folder a Save As style dialog should open in for a document stored at `path`: the /// document's own folder, as Acrobat and Foxit do, instead of whichever folder the system picks. /// `None` (keep the system's choice) for a new document, a path with no parent, or an email diff --git a/crates/ui-egui/src/editing/tests.rs b/crates/ui-egui/src/editing/tests.rs index c0e35329c0..b53ac49305 100644 --- a/crates/ui-egui/src/editing/tests.rs +++ b/crates/ui-egui/src/editing/tests.rs @@ -1,4 +1,15 @@ -use super::{SaveTarget, is_mail_temp_copy, save_dialog_folder, write_atomically, write_atomically_syncing, write_atomically_with}; +use super::{ + SaveTarget, is_mail_temp_copy, save_dialog_folder, unredacted_file_name, write_atomically, write_atomically_syncing, write_atomically_with, +}; + +#[test] +fn restored_content_is_not_proposed_under_the_redacted_name() { + assert_eq!(unredacted_file_name("contract_Redacted.pdf"), "contract.pdf"); + assert_eq!(unredacted_file_name("SCAN_Redacted.PDF"), "SCAN.PDF"); + assert_eq!(unredacted_file_name("Untitled_Redacted"), "Untitled"); + assert_eq!(unredacted_file_name("contract.pdf"), "contract.pdf"); + assert_eq!(unredacted_file_name("_Redacted.pdf"), "_Redacted.pdf"); +} #[test] fn save_dialogs_open_in_the_documents_folder() { diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index 3e11861cbc..ccb3a267ef 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -4,7 +4,7 @@ use egui::Pos2; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; -use pdfcraft_ui_egui::{Dialog, PdfCraftApp}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, SaveTarget}; fn harness() -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { @@ -74,6 +74,417 @@ fn marking_applying_and_clearing() { assert!(doc.form.is_empty(), "the whole page was redacted, fields included"); } +/// After Apply redactions, Save asks where instead of writing over the opened file, as Acrobat +/// shows Save As with `_Redacted` appended to the name. Once that copy is written, Save writes +/// it in place again. +#[test] +fn saving_after_applying_redactions_asks_where() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + let source = include_bytes!("../data/form.pdf").to_vec(); + std::fs::write(&original, &source).unwrap(); + let path = original.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(marks(&h), 1); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(marks(&h), 0); + assert_eq!(h.state().views[0].redactions_applied, 1); + let copy = dir.join("contract_Redacted.pdf"); + h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where first"); + h.run_steps(3); + assert_eq!(std::fs::read(&original).unwrap(), source, "the opened file is left as it was"); + assert!(copy.exists(), "the redacted copy is written where the user chose"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract_Redacted.pdf"); + assert_eq!(s.views[0].redactions_applied, 0); + h.state_mut().pick_override = None; + assert!(h.state_mut().save_active(SaveTarget::InPlace), "the next Save writes the copy in place"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Undo past an Apply redactions that is already saved in the `_Redacted` copy makes Save ask +/// where, so the restored content is not written over the copy. Redo makes Save write in place. +#[test] +fn undoing_saved_redactions_does_not_save_over_the_redacted_copy() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-saved-undo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + let copy = dir.join("contract_Redacted.pdf"); + h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.run_steps(3); + h.state_mut().pick_override = None; + assert!(copy.exists()); + // Undo and Redo leave the saved redactions in effect: Save writes the copy in place. + h.state_mut().execute("edit.undo"); + h.run_steps(3); + h.state_mut().execute("edit.redo"); + h.run_steps(3); + assert_eq!((h.state().views[0].redactions_applied, h.state().views[0].redactions_undone_after_save), (0, 0)); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "with the redactions back, Save writes the copy in place"); + let saved = std::fs::read(©).unwrap(); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_undone_after_save, 1); + let elsewhere = dir.join("contract_restored.pdf"); + h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where instead of writing over the copy"); + h.run_steps(3); + h.state_mut().pick_override = None; + assert_eq!(std::fs::read(©).unwrap(), saved, "the redacted copy is left as it was"); + assert!(elsewhere.exists(), "the restored version is written where the user chose"); + assert_eq!(h.state().views[0].redactions_undone_after_save, 0); + // The restored version is now the saved one: Redo applies the redactions to it again. + h.state_mut().execute("edit.redo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 1); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A new Apply redactions after Undo past a saved one clears Redo, so Undo and Redo of the new +/// step do not bring Save in place back over the `_Redacted` copy. +#[test] +fn redoing_a_new_apply_after_undoing_saved_redactions_still_asks_where() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-branch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + let copy = dir.join("contract_Redacted.pdf"); + h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.run_steps(3); + h.state_mut().pick_override = None; + let saved = std::fs::read(©).unwrap(); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + // Apply the marks again as a new step, then Undo and Redo it. + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + let view = &h.state().views[0]; + assert_eq!((view.redactions_applied, view.redactions_undone_after_save, view.saved_redactions_cut_off), (0, 0, true)); + h.state_mut().execute("edit.redo"); + h.run_steps(3); + let elsewhere = dir.join("contract_again.pdf"); + h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where instead of writing over the copy"); + h.run_steps(3); + h.state_mut().pick_override = None; + assert_eq!(std::fs::read(©).unwrap(), saved, "the redacted copy is left as it was"); + assert!(!h.state().views[0].saved_redactions_cut_off); + let _ = std::fs::remove_dir_all(&dir); +} + +/// In the web build a delivered save keeps `redactions_applied` and records it in +/// `redactions_downloaded`. Undo past a delivered Apply redactions, or a new Apply after it, makes +/// Save ask where, so a host does not write the restored content over the redacted copy. +#[test] +fn undoing_delivered_redactions_asks_where() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-delivered-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + // The state a web save leaves: the step is delivered and stays counted. + h.state_mut().views[0].redactions_downloaded = 1; + h.state_mut().execute("edit.undo"); + h.run_steps(3); + let elsewhere = dir.join("contract_restored.pdf"); + h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where after Undo past a delivered step"); + h.run_steps(3); + h.state_mut().pick_override = None; + assert!(elsewhere.exists()); + // A new Apply after Undo past a delivered step cannot be redone back to the delivered one. + h.state_mut().views[0].redactions_downloaded = 1; + h.state_mut().views[0].redactions_applied = 0; + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert!(h.state().views[0].saved_redactions_cut_off); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Once every Apply redactions step is delivered, Save writes in place again even though the web +/// build keeps the steps counted for the `_Redacted` name. +#[test] +fn delivered_redactions_save_in_place() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-delivered-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + // The state a web save leaves: the step is delivered and stays counted. + h.state_mut().views[0].redactions_downloaded = 1; + assert!(h.state_mut().save_active(SaveTarget::InPlace), "Save writes in place after the step is delivered"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Undo past a saved Apply redactions while the Save dialog is open makes Save ask again, so the +/// restored content is not written to the redacted copy the dialog proposed. +#[test] +fn undoing_saved_redactions_while_the_save_dialog_is_open_asks_again() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-dialog-undo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&path); + app + }); + crate::support::settle(&mut h); + let apply = |h: &mut Harness<'static, PdfCraftApp>| { + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + }; + apply(&mut h); + let copy = dir.join("contract_Redacted.pdf"); + h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.run_steps(3); + let saved = std::fs::read(©).unwrap(); + // A second Apply redactions makes Save ask where. Before the answer, Undo takes back the + // second Apply, its marks and the saved Apply. + apply(&mut h); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + for _ in 0..3 { + h.state_mut().execute("edit.undo"); + } + assert_eq!(h.state().views[0].redactions_undone_after_save, 1); + let elsewhere = dir.join("contract_restored.pdf"); + h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]); + h.run_steps(4); + h.state_mut().pick_override = None; + assert_eq!(std::fs::read(©).unwrap(), saved, "the redacted copy is left as it was"); + assert!(elsewhere.exists(), "Save asked again and wrote where the user chose"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Undo of Apply redactions while the Save dialog that proposed the `_Redacted` name is open +/// makes Save ask again, so the unredacted content is not saved under that name. +#[test] +fn undoing_apply_redactions_while_the_save_dialog_is_open_asks_again() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-dialog-unapply-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + let copy = dir.join("contract_Redacted.pdf"); + h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.state_mut().execute("edit.undo"); + assert_eq!(h.state().views[0].redactions_applied, 0); + let elsewhere = dir.join("contract_copy.pdf"); + h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]); + h.run_steps(4); + h.state_mut().pick_override = None; + assert!(!copy.exists(), "the unredacted content is not saved under the _Redacted name"); + assert!(elsewhere.exists(), "Save asked again and wrote where the user chose"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Undo of Apply redactions brings back Save in place; Redo makes Save ask where again. +#[test] +fn undoing_apply_redactions_saves_in_place_again() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-undo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&path); + app + }); + crate::support::settle(&mut h); + for _ in 0..2 { + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + } + assert_eq!(h.state().views[0].redactions_applied, 2); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 1, "the first Apply redactions is still in effect"); + // Undoing the second mark leaves the first Apply redactions in effect. + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 1); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 0); + h.state_mut().execute("edit.redo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 1); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "with the redactions undone, Save writes in place"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract.pdf"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File ▸ Revert discards the applied redactions, so Save writes the opened file in place again. +#[test] +fn reverting_after_apply_redactions_saves_in_place_again() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-revert-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(h.state().views[0].redactions_applied, 1); + assert!(h.state_mut().execute("file.revert")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::Revert)); + h.get_all_by_label("Revert").last().expect("the button").click(); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 0); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "after Revert, Save writes in place"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract.pdf"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn redaction_codes_become_the_overlay_text() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8288146bee..eceeee30fc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6000,11 +6000,11 @@ title = "Add a suffix to redacted file names" area = "G" tier = "P3" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.apply", "file.save"] +tools = ["redact_apply"] +evidence = ["crates/ui-egui/tests/it/redact.rs::saving_after_applying_redactions_asks_where", "crates/automation/tests/it/automation.rs::applying_redactions_proposes_the_name_with_redacted", "crates/redact/src/tests.rs::redacted_copies_are_named_with_redacted_before_the_extension"] +notes = "After Apply redactions, Save asks where and proposes the name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name already ending in _Redacted keeps one); the web build downloads under that name. redact_apply returns the same name in save_as, for doc_save's path; doc_save without a path still writes in place. The suffix is fixed: there is no preference to change it or to use a prefix." [[feature]] id = "protect.redact-ai-suggestions"