diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 1b6283c0a..e662bcac1 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -14,9 +14,19 @@ impl Automation { pub(crate) fn redact_mark(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, n) = (doc.id, doc.info.pages.len()); - let pages: Vec = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), + if a.get("page").is_some() && a.get("pages").is_some() { + return Err(ToolError::InvalidArgs("pass page or pages, not both".into())); + } + if a.get("rect").is_some() && a.get("pages").is_some() { + return Err(ToolError::InvalidArgs("rect requires page, not pages".into())); + } + let pages: Vec = if a.get("page").is_some() { + vec![self.page(a)?] + } else { + match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + } }; let overlay = match (a.opt_str("overlay")?, a.opt_str("code_set")?) { (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass overlay or code_set with codes, not both".into())), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 59ecf0425..a48d4dd99 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -427,12 +427,12 @@ pub fn tools() -> Vec { t( "redact_mark", "Mark for redaction", - "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", + "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", ) .with(schema( json!({ "doc": doc(), - "page": { "type": "integer", "minimum": 1 }, + "page": { "type": "integer", "minimum": 1, "description": "Single page to mark; mutually exclusive with pages." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "find": { "type": "string", "minLength": 1 }, "words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 }, @@ -440,7 +440,7 @@ pub fn tools() -> Vec { "whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." }, "pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] }, "whole_pages": { "type": "boolean" }, - "pages": pages("to search or mark (default: all)"), + "pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"), "overlay": { "type": "string" }, "code_set": { "type": "string", "enum": ["foia", "privacy-act"] }, "codes": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "Redaction codes from code_set, e.g. [\"(b)(6)\"]." }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 2c87162d5..1d11304c9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3391,6 +3391,36 @@ fn rotating_a_field_through_tools() { assert_eq!((rot, back), (0, before)); } +#[test] +fn redact_mark_respects_single_page_and_rejects_conflicting_selectors() { + let dir = workdir("redact-single-page"); + std::fs::write(dir.join("memo.txt"), "Private 123-45-6789").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_duplicate", json!({ "doc": doc, "pages": [1] })); + for selector in [json!({ "find": "Private" }), json!({ "words": ["Private"] }), json!({ "pattern": "ssn" }), json!({ "whole_pages": true })] { + let mut args = selector; + args["doc"] = json!(doc); + args["page"] = json!(1); + let result = ok(&mut a, "redact_mark", args); + assert_eq!(result["marked"], 1); + ok(&mut a, "redact_clear", json!({ "doc": doc })); + } + for args in [ + json!({ "doc": doc, "find": "Private", "page": 1, "pages": [2] }), + json!({ "doc": doc, "rect": [1, 1, 10, 10], "pages": [2] }), + json!({ "doc": doc, "find": "Private", "page": 3 }), + ] { + assert!(matches!(a.call("redact_mark", &args), Err(ToolError::InvalidArgs(_)))); + } + let result = ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "Private", "page": 1 })); + assert_eq!(result["marks_pending"], 1, "invalid selections must not add marks"); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + let text = page_text(&mut a, doc); + assert!(!text[0].contains("Private")); + assert!(text[1].contains("Private")); +} + #[test] fn redacting_through_tools() { let dir = workdir("redact");