mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge #4497: M8: after Apply redactions, Save asks where and proposes the name with _Redacted (protect.redact-filename-suffix)
This commit is contained in:
commit
117f3b1102
14 files changed
+577
-10
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -678,7 +678,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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"])),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1009,3 +1009,15 @@ pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result<usize,
|
||||
}
|
||||
Ok(targets.len())
|
||||
}
|
||||
|
||||
/// The name to save a document under after [`apply`]: `_Redacted` before the extension, as
|
||||
/// Acrobat appends it ("contract.pdf" gives "contract_Redacted.pdf"). The `.pdf` extension is
|
||||
/// matched in any letter case. A name that already ends in `_Redacted` keeps it once.
|
||||
pub fn redacted_file_name(name: &str) -> 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") }
|
||||
}
|
||||
@@ -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![]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user