diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index e8a47594..e831f917 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -259,6 +259,57 @@ impl Automation { Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns })) } + pub(crate) fn action_list(&self) -> Result { + use printcraft_engine::actions::{Step, builtin}; + let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() }); + let actions: Vec = builtin() + .iter() + .map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::>() })) + .collect(); + let steps: Vec = Step::all().iter().map(|s| json!({ "step": s.id(), "label": s.label(), "takes_arg": s.arg().is_some() })).collect(); + Ok(json!({ "actions": actions, "steps": steps })) + } + + pub(crate) fn action_run(&mut self, a: &Args) -> Result { + use printcraft_engine::actions::{Action, Step, builtin, run_on}; + let action = if let Some(name) = a.opt_str("action")? { + builtin() + .into_iter() + .find(|x| x.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| ToolError::InvalidArgs(format!("no built-in action {name:?}")))? + } else { + let items = a.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("give action or steps".into()))?; + let steps = items + .iter() + .map(|it| { + let id = it["step"].as_str().unwrap_or(""); + Step::from_id(id, it["arg"].as_str().unwrap_or("")).ok_or_else(|| ToolError::InvalidArgs(format!("unknown step {id:?}"))) + }) + .collect::>>()?; + Action { name: "Custom".into(), description: String::new(), steps, builtin: false } + }; + let folder = self.resolve(a.str("folder")?, true)?; + std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; + let mut out = Vec::new(); + for p in a.strs("paths")? { + let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); + let result = self + .resolve(p, false) + .map_err(|e| e.to_string()) + .and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())) + .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); + out.push(match result { + Ok(r) => { + let target = folder.join(&name); + write_atomic(&target, &r.bytes)?; + json!({ "path": p, "output": target.to_string_lossy(), "log": r.log }) + } + Err(e) => json!({ "path": p, "error": e }), + }); + } + Ok(json!({ "action": action.name, "files": out })) + } + fn compare_ids(&self, a: &Args) -> Result<(printcraft_engine::DocId, printcraft_engine::DocId)> { let new = self.doc(a)?.id; let other = a.int("other")?; diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 4afd46e4..ea669cea 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -512,6 +512,8 @@ impl Automation { "form_actions" => self.form_actions(&a)?, "form_detect_fields" => self.form_detect_fields(&a)?, "doc_compare" => self.doc_compare(&a)?, + "action_list" => self.action_list()?, + "action_run" => self.action_run(&a)?, "doc_compare_report" => self.doc_compare_report(&a)?, "doc_compare_mark" => self.doc_compare_mark(&a)?, "form_set_actions" => self.form_set_actions(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index fa1edfda..7f6a2217 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -759,6 +759,21 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), + t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).") + .ro() + .cmd("actions.wizard") + .with(schema(json!({}), &[])), + t("action_run", "Run an action", "Action Wizard: run a built-in action (action: its name) or a list of steps ([{step, arg}], ids from action_list) on each PDF in paths, writing the results into folder under the same names. Returns each file's step log or error.") + .cmd("actions.wizard") + .with(schema( + json!({ + "action": { "type": "string" }, + "steps": { "type": "array", "items": { "type": "object", "properties": { "step": { "type": "string" }, "arg": { "type": "string" } }, "required": ["step"] } }, + "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "folder": { "type": "string" }, + }), + &["paths", "folder"], + )), t("doc_compare", "Compare files", "Compare the text of two open documents: other is the older version, doc the newer. Returns counts and each change (replaced, inserted, deleted) with the old and new text, pages (1-based) and rectangles (points, origin bottom-left).") .ro() .cmd("doc.compare") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 624bcb53..aa5c7475 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1453,3 +1453,20 @@ fn comparing_documents_through_tools() { assert_eq!(ok(&mut a, "doc_compare_mark", json!({ "doc": v2, "other": v1 }))["comments"], 3); assert!(a.call("doc_compare", &json!({ "doc": v2, "other": 999 })).is_err()); } + +#[test] +fn actions_through_tools() { + let dir = workdir("actions"); + let mut a = auto(&dir); + let list = ok(&mut a, "action_list", json!({})); + assert!(list["actions"].as_array().unwrap().iter().any(|x| x["name"] == "Prepare for Distribution")); + assert!(list["steps"].as_array().unwrap().iter().any(|x| x["step"] == "add_watermark" && x["takes_arg"] == true)); + let r = ok(&mut a, "action_run", json!({ "action": "add page numbers", "paths": ["a.pdf", "b.pdf", "missing.pdf"], "folder": "out" })); + assert_eq!(r["files"][0]["log"][0], "Add footer"); + assert!(r["files"][2]["error"].is_string()); + let doc = ok(&mut a, "doc_open", json!({ "path": "out/b.pdf" }))["doc"].as_u64().unwrap(); + assert!(page_text(&mut a, doc)[1].contains("Page 2 of 2"), "{:?}", page_text(&mut a, doc)); + let r = ok(&mut a, "action_run", json!({ "steps": [{ "step": "set_title", "arg": "Hello" }], "paths": ["a.pdf"], "folder": "out2" })); + assert_eq!(r["files"][0]["log"][0], "Set document title"); + assert!(a.call("action_run", &json!({ "steps": [{ "step": "fly" }], "paths": ["a.pdf"], "folder": "x" })).is_err()); +} diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs new file mode 100644 index 00000000..938a8d4b --- /dev/null +++ b/crates/engine/src/actions.rs @@ -0,0 +1,220 @@ +//! Action Wizard: named sequences of steps run over files (Acrobat's guided actions), the +//! built-in actions, and running an action over many files (batch processing). + +use std::sync::Arc; + +use crate::{DocId, Edit, EditError, Session}; + +/// One step of an action. +#[derive(Clone, Debug, PartialEq)] +pub enum Step { + /// Scan & OCR ▸ Recognize text (pages without text). + RecognizeText, + /// Reduce file size. + ReduceFileSize, + /// Remove hidden information (every category). + RemoveHiddenInformation, + /// Sanitize document. + Sanitize, + FlattenComments, + FlattenFields, + /// Add a text watermark (diagonal, 50% opaque). + AddWatermark(String), + /// Add a footer with this centre text (tokens such as `<>` work). + AddFooter(String), + /// Add a header with this centre text. + AddHeader(String), + /// Set the document title. + SetTitle(String), + /// Prepare a form: detect fields. + DetectFormFields, + /// Run a JavaScript (console context). + RunJavaScript(String), +} + +impl Step { + /// Every step, with an empty argument where it takes one (for the step picker). + pub fn all() -> Vec { + vec![ + Step::RecognizeText, + Step::ReduceFileSize, + Step::RemoveHiddenInformation, + Step::Sanitize, + Step::FlattenComments, + Step::FlattenFields, + Step::AddWatermark(String::new()), + Step::AddHeader(String::new()), + Step::AddFooter(String::new()), + Step::SetTitle(String::new()), + Step::DetectFormFields, + Step::RunJavaScript(String::new()), + ] + } + + /// A stable id (agents, saved actions). + pub fn id(&self) -> &'static str { + match self { + Step::RecognizeText => "recognize_text", + Step::ReduceFileSize => "reduce_file_size", + Step::RemoveHiddenInformation => "remove_hidden_information", + Step::Sanitize => "sanitize", + Step::FlattenComments => "flatten_comments", + Step::FlattenFields => "flatten_fields", + Step::AddWatermark(_) => "add_watermark", + Step::AddHeader(_) => "add_header", + Step::AddFooter(_) => "add_footer", + Step::SetTitle(_) => "set_title", + Step::DetectFormFields => "detect_form_fields", + Step::RunJavaScript(_) => "run_javascript", + } + } + + pub fn label(&self) -> &'static str { + match self { + Step::RecognizeText => "Recognize text", + Step::ReduceFileSize => "Reduce file size", + Step::RemoveHiddenInformation => "Remove hidden information", + Step::Sanitize => "Sanitize document", + Step::FlattenComments => "Flatten comments", + Step::FlattenFields => "Flatten form fields", + Step::AddWatermark(_) => "Add watermark", + Step::AddHeader(_) => "Add header", + Step::AddFooter(_) => "Add footer", + Step::SetTitle(_) => "Set document title", + Step::DetectFormFields => "Detect form fields", + Step::RunJavaScript(_) => "Run a JavaScript", + } + } + + /// The step's text argument, if it takes one. + pub fn arg(&self) -> Option<&str> { + match self { + Step::AddWatermark(s) | Step::AddHeader(s) | Step::AddFooter(s) | Step::SetTitle(s) | Step::RunJavaScript(s) => Some(s), + _ => None, + } + } + + pub fn arg_mut(&mut self) -> Option<&mut String> { + match self { + Step::AddWatermark(s) | Step::AddHeader(s) | Step::AddFooter(s) | Step::SetTitle(s) | Step::RunJavaScript(s) => Some(s), + _ => None, + } + } + + /// Build a step from its id and argument. + pub fn from_id(id: &str, arg: &str) -> Option { + let mut s = Step::all().into_iter().find(|s| s.id() == id)?; + if let Some(a) = s.arg_mut() { + *a = arg.to_string(); + } + Some(s) + } +} + +/// A named action. +#[derive(Clone, Debug, PartialEq)] +pub struct Action { + pub name: String, + pub description: String, + pub steps: Vec, + /// One of PrintCraft's own actions (can't be edited or deleted). + pub builtin: bool, +} + +/// The built-in actions. +pub fn builtin() -> Vec { + let a = |name: &str, description: &str, steps: Vec| Action { name: name.into(), description: description.into(), steps, builtin: true }; + vec![ + a( + "Prepare for Distribution", + "Remove hidden information and flatten comments and form fields, so recipients see only the pages.", + vec![Step::RemoveHiddenInformation, Step::FlattenComments, Step::FlattenFields], + ), + a("Optimize Scanned Documents", "Make scans searchable, then make them smaller.", vec![Step::RecognizeText, Step::ReduceFileSize]), + a("Add Page Numbers", "Number every page in the footer.", vec![Step::AddFooter("Page <<1>> of <>".into())]), + a("Mark as Confidential", "A diagonal CONFIDENTIAL watermark on every page.", vec![Step::AddWatermark("CONFIDENTIAL".into())]), + a("Sanitize Documents", "Remove hidden information, metadata, scripts and embedded content, and rewrite the files.", vec![Step::Sanitize]), + ] +} + +/// What running an action on one file did. +#[derive(Clone, Debug, PartialEq)] +pub struct FileResult { + pub name: String, + pub bytes: Arc>, + /// One line per step. + pub log: Vec, +} + +fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> Result, EditError> { + let pages = s.get(id).map_or(0, |d| d.info.pages.len()); + let all: Vec = (0..pages).collect(); + let edit = match step { + Step::RecognizeText => { + let found = s.recognize_text(id, &[], crate::ocr::OcrSettings::default()).map_err(EditError::Invalid)?; + log.push(format!("Recognize text: {} words", found.iter().map(|p| p.words.len()).sum::())); + return Ok(None); + } + Step::ReduceFileSize => { + let (bytes, _) = s.optimized_bytes(id, &crate::optimize::Settings::default(), &[])?; + let before = s.get(id).map_or(0, |d| d.bytes.len()); + log.push(format!("Reduce file size: {} → {} bytes", before, bytes.len())); + let name = s.get(id).map(|d| d.name.clone()).unwrap_or_default(); + s.close(id); + let new = s.open(name, None, bytes, None).map_err(|e| EditError::Reopen(e.to_string()))?; + return Ok(Some(new)); + } + Step::RemoveHiddenInformation => Edit::RemoveHidden { which: crate::HIDDEN.to_vec() }, + Step::Sanitize => Edit::Sanitize, + Step::FlattenComments => Edit::Flatten { comments: true, fields: false }, + Step::FlattenFields => Edit::Flatten { comments: false, fields: true }, + Step::AddWatermark(t) => { + Edit::AddWatermark { pages: all, settings: crate::Watermark { text: t.clone(), ..Default::default() }, replace: false, file: None } + } + Step::AddHeader(t) | Step::AddFooter(t) => { + let mut settings = crate::HeaderFooter::default(); + settings.text[if matches!(step, Step::AddHeader(_)) { 1 } else { 4 }] = t.clone(); + Edit::AddHeaderFooter { pages: all, settings, replace: false } + } + Step::SetTitle(t) => Edit::SetInfo { key: "Title".into(), value: t.clone() }, + Step::DetectFormFields => { + let n = s.auto_detect_fields(id, &[])?.len(); + log.push(format!("Detect form fields: {n}")); + return Ok(None); + } + Step::RunJavaScript(js) => { + let o = s.run_javascript(id, js, None)?; + log.push(match o.error { + Some(e) => format!("Run a JavaScript: {e}"), + None => "Run a JavaScript".into(), + }); + return Ok(None); + } + }; + // Steps that find nothing to do (no comments to flatten, …) aren't failures. + match s.apply(id, edit) { + Ok(()) => log.push(step.label().to_string()), + Err(e) => log.push(format!("{}: {e}", step.label())), + } + Ok(None) +} + +/// Run `action` on one file. `progress(step, steps)` is called before each step. +pub fn run_on(action: &Action, name: &str, bytes: Arc>, mut progress: impl FnMut(usize, usize)) -> Result { + let mut s = Session::new(); + let mut id = s.open(name, None, bytes, None).map_err(|e| e.to_string())?; + if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) { + return Err(why); + } + let mut log = Vec::new(); + for (i, step) in action.steps.iter().enumerate() { + progress(i, action.steps.len()); + if let Some(new) = apply_step(&mut s, id, step, &mut log).map_err(|e| format!("{}: {e}", step.label()))? { + id = new; + } + } + let d = s.get(id).ok_or("the document was lost")?; + // A signed file keeps its signatures: incremental. Otherwise a clean full rewrite. + let bytes = if d.is_signed() { s.save_bytes(id) } else { s.save_full_bytes(id) }.map_err(|e| e.to_string())?; + Ok(FileResult { name: name.to_string(), bytes, log }) +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index b4fc17d4..81c801e1 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -422,13 +422,14 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ icon: "list-checks", hue: PURPLE, badge: None, - availability: Planned("M13"), + availability: Ready, sections: &[ToolSection { title: "Actions list", items: &[ + item("Action Wizard", "list-checks", "actions.wizard", Ready), item("Make accessible", "accessibility", "actions.make_accessible", Planned("M13")), - item("Prepare for distribution", "send", "actions.distribution", Planned("M13")), - item("Optimize scanned documents", "scan-text", "actions.optimize_scans", Planned("M13")), + item("Prepare for distribution", "send", "actions.distribution", Ready), + item("Optimize scanned documents", "scan-text", "actions.optimize_scans", Ready), item("Archive documents", "file-check", "actions.archive", Planned("M13")), ], }], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5a51e3b9..3b0364b2 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -201,6 +201,9 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.summarize", "Summarize comments", None, None, HasComments, "file-text"), c("form.import_data", "Import form data…", None, None, HasFields, "file-input"), c("form.export_data", "Export form data…", None, None, HasFields, "file-output"), + c("actions.wizard", "Action Wizard…", None, None, Nothing, "list-checks"), + c("actions.distribution", "Prepare for distribution…", None, None, Nothing, "send"), + c("actions.optimize_scans", "Optimize scanned documents…", None, None, Nothing, "scan-text"), c("doc.compare", "Compare files…", None, None, Document, "git-compare"), c("form.detect", "Detect form fields", None, None, Modification, "scan"), c("form.merge_data", "Merge data files into spreadsheet…", None, None, Nothing, "file-spreadsheet"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3367d6d6..3ca4298e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ //! appended revision — and the view is refreshed from it, so what you see is exactly what Save //! will write. Saving rebases onto the written bytes, so the next save appends only new edits. +pub mod actions; pub mod catalog; pub mod commands; pub mod compare; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 21714d3a..9e75e365 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1350,3 +1350,37 @@ fn comparing_two_versions_of_a_document() { assert_eq!(marks.len(), 2); assert!(marks[0].contents.as_deref().unwrap().starts_with("Replaced: \"Monday.\"")); } + +#[test] +fn actions_run_their_steps_on_files() { + let s = Session::new(); + let src = s.create_from_text("t", "Quarterly numbers").unwrap(); + let built = actions::builtin(); + let numbers = built.iter().find(|a| a.name == "Add Page Numbers").unwrap(); + let r = actions::run_on(numbers, "q.pdf", src.clone(), |_, _| {}).unwrap(); + let mut s2 = Session::new(); + let id = s2.open("out.pdf", None, r.bytes, None).unwrap(); + assert!(page_texts(&s2, id)[0].contains("Page 1 of 1"), "{:?}", page_texts(&s2, id)); + assert_eq!(r.log, ["Add footer"]); + + let custom = actions::Action { + name: "Mine".into(), + description: String::new(), + steps: vec![ + actions::Step::from_id("set_title", "Q3").unwrap(), + actions::Step::AddWatermark("DRAFT".into()), + actions::Step::ReduceFileSize, + actions::Step::FlattenComments, + ], + builtin: false, + }; + let mut steps = Vec::new(); + let r = actions::run_on(&custom, "q.pdf", src, |i, n| steps.push((i, n))).unwrap(); + assert_eq!(steps, [(0, 4), (1, 4), (2, 4), (3, 4)]); + assert_eq!(r.log.len(), 4, "{:?}", r.log); + let id = s2.open("out2.pdf", None, r.bytes, None).unwrap(); + let d = s2.get(id).unwrap(); + assert_eq!(d.info.title.as_deref(), Some("Q3")); + assert!(page_texts(&s2, id)[0].contains("DRAFT")); + assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err()); +} diff --git a/crates/ui-egui/src/actions_ui.rs b/crates/ui-egui/src/actions_ui.rs new file mode 100644 index 00000000..04058551 --- /dev/null +++ b/crates/ui-egui/src/actions_ui.rs @@ -0,0 +1,330 @@ +//! Action Wizard: the actions (PrintCraft's and your own), running one over files in the +//! background, and creating or editing an action's steps. Your actions are remembered. + +use std::sync::{Arc, Mutex}; + +use egui::{Align, Layout}; +use printcraft_engine::actions::{Action, Step, builtin}; + +use crate::theme::{self, Tokens}; +use crate::{PrintCraftApp, widgets}; + +/// The Action Wizard's state. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Wizard { + /// The selected action, by name. + pub selected: Option, + /// The action being created or edited (and the name it had). + pub editing: Option<(Option, Action)>, + /// The step the "Add" menu adds. + pub add: usize, +} + +/// Progress of a run: files done, total, and the summary once finished. +#[derive(Default)] +pub struct RunProgress { + pub done: usize, + pub total: usize, + pub message: Option, +} + +impl PrintCraftApp { + /// Built-in actions, then the user's. + pub fn all_actions(&self) -> Vec { + let mut v = builtin(); + v.extend(self.custom_actions.iter().cloned()); + v + } + + /// Run `action` on files, writing the results into a folder the user picks (the export + /// folder override in tests), in the background. + pub fn run_action_on(&mut self, action: Action, files: Vec<(String, Vec)>) { + if self.action_run.is_some() { + self.notify("An action is already running"); + return; + } + #[cfg(not(target_arch = "wasm32"))] + let dir = match &self.export_dir_override { + Some(d) => Some(std::path::PathBuf::from(d)), + None => rfd::FileDialog::new().set_title("Choose a folder for the results").pick_folder(), + }; + #[cfg(not(target_arch = "wasm32"))] + let Some(dir) = dir else { return }; + let progress = Arc::new(Mutex::new(RunProgress { total: files.len(), ..Default::default() })); + let p = progress.clone(); + let work = move || { + let (mut ok, mut failed) = (0, Vec::new()); + for (i, (name, bytes)) in files.into_iter().enumerate() { + if let Ok(mut s) = p.lock() { + s.done = i; + } + let r = printcraft_engine::actions::run_on(&action, &name, Arc::new(bytes), |_, _| {}).and_then(|r| { + #[cfg(not(target_arch = "wasm32"))] + crate::editing::write_atomically(&dir.join(&name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + crate::editing::download(&name, &r.bytes)?; + Ok(()) + }); + match r { + Ok(()) => ok += 1, + Err(e) => failed.push(format!("{name}: {e}")), + } + } + let mut msg = format!("{}: {ok} file{} done", action.name, if ok == 1 { "" } else { "s" }); + if !failed.is_empty() { + msg.push_str(&format!("; failed: {}", failed.join("; "))); + } + if let Ok(mut s) = p.lock() { + s.done = s.total; + s.message = Some(msg); + } + }; + #[cfg(not(target_arch = "wasm32"))] + if self.run_inline { + work(); + } else { + std::thread::Builder::new().name("printcraft-action".into()).spawn(work).ok(); + } + #[cfg(target_arch = "wasm32")] + work(); + self.action_run = Some(progress); + self.poll_action(); + } + + /// Show the running action's progress, and its summary when done. + pub(crate) fn poll_action(&mut self) { + let Some(p) = self.action_run.clone() else { return }; + let state = p.lock().ok().map(|mut s| s.message.take().ok_or((s.done, s.total))); + match state { + Some(Ok(m)) => { + self.action_run = None; + self.notify(m); + } + Some(Err((done, total))) => { + let m = format!("Running action… file {} of {}", (done + 1).min(total.max(1)), total.max(1)); + if self.toast.as_ref().is_none_or(|t| t.0 != m) { + self.notify(m); + } + if let Some(ctx) = &self.ctx { + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } + } + None => self.action_run = None, + } + } + + /// Start: ask for the files, then run the selected action on them. + fn start_selected_action(&mut self) { + let Some(action) = self.wizard.selected.as_ref().and_then(|n| self.all_actions().into_iter().find(|a| &a.name == n)) else { return }; + #[cfg(not(target_arch = "wasm32"))] + { + let paths = match &self.action_files_override { + Some(p) => p.iter().map(std::path::PathBuf::from).collect(), + None => rfd::FileDialog::new() + .set_title(format!("Files for {}", action.name)) + .add_filter("PDF", &["pdf"]) + .pick_files() + .unwrap_or_default(), + }; + let mut files = Vec::new(); + for p in paths { + let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + match std::fs::read(&p) { + Ok(b) => files.push((name, b)), + Err(e) => return self.notify(format!("Couldn't read {name}: {e}")), + } + } + if !files.is_empty() { + self.run_action_on(action, files); + } + } + #[cfg(target_arch = "wasm32")] + { + // The open document stands in for a file picker. + if let Some(doc) = self.active_ids().and_then(|(_, id)| self.session.get(id)) { + let files = vec![(doc.name.clone(), doc.bytes.to_vec())]; + self.run_action_on(action, files); + } + } + } +} + +/// Saved actions as JSON (`[{name, description, steps: [[id, arg], …]}]`). +pub fn encode(actions: &[Action]) -> serde_json::Value { + serde_json::Value::Array( + actions + .iter() + .map(|a| { + let steps: Vec = a.steps.iter().map(|s| serde_json::json!([s.id(), s.arg().unwrap_or("")])).collect(); + serde_json::json!({ "name": a.name, "description": a.description, "steps": steps }) + }) + .collect(), + ) +} + +pub fn decode(v: &serde_json::Value) -> Vec { + v.as_array() + .map(|list| { + list.iter() + .filter_map(|a| { + let name = a["name"].as_str()?.to_string(); + let steps = a["steps"].as_array()?.iter().filter_map(|s| Step::from_id(s[0].as_str()?, s[1].as_str().unwrap_or(""))).collect(); + Some(Action { name, description: a["description"].as_str().unwrap_or("").to_string(), steps, builtin: false }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// The Action Wizard dialog. Returns `true` to close. +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { + if app.wizard.editing.is_some() { + return edit_body(ui, app, t); + } + ui.label(egui::RichText::new("Action Wizard").font(theme::semibold(18.0))); + ui.add_space(8.0); + let actions = app.all_actions(); + if app.wizard.selected.as_ref().is_none_or(|s| !actions.iter().any(|a| &a.name == s)) { + app.wizard.selected = actions.first().map(|a| a.name.clone()); + } + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(220.0); + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(6)).show(ui, |ui| { + ui.set_width(208.0); + ui.set_min_height(260.0); + for a in &actions { + let on = app.wizard.selected.as_deref() == Some(a.name.as_str()); + if ui.selectable_label(on, &a.name).clicked() { + app.wizard.selected = Some(a.name.clone()); + } + } + }); + }); + ui.vertical(|ui| { + let Some(a) = actions.iter().find(|a| app.wizard.selected.as_deref() == Some(a.name.as_str())) else { return }; + ui.label(egui::RichText::new(&a.name).font(theme::semibold(15.0))); + if !a.description.is_empty() { + ui.label(egui::RichText::new(&a.description).color(t.text_muted)); + } + ui.add_space(6.0); + ui.label(egui::RichText::new("Steps").font(theme::semibold(13.0))); + for (i, s) in a.steps.iter().enumerate() { + let arg = s.arg().filter(|x| !x.is_empty()).map(|x| format!(": {x}")).unwrap_or_default(); + ui.label(format!("{}. {}{arg}", i + 1, s.label())); + } + }); + }); + ui.add_space(10.0); + let selected_custom = app.wizard.selected.as_ref().and_then(|n| app.custom_actions.iter().position(|a| &a.name == n)); + let mut close = false; + ui.horizontal(|ui| { + if ui.button("New Action…").clicked() { + app.wizard.editing = Some((None, Action { name: String::new(), description: String::new(), steps: Vec::new(), builtin: false })); + } + if let Some(i) = selected_custom { + if ui.button("Edit…").clicked() { + let a = app.custom_actions[i].clone(); + app.wizard.editing = Some((Some(a.name.clone()), a)); + } + if ui.button("Delete").clicked() { + app.custom_actions.remove(i); + app.wizard.selected = None; + } + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, "Start", true).clicked() { + close = true; + app.start_selected_action(); + } + if widgets::pill_button(ui, "Close", false).clicked() { + close = true; + } + }); + }); + close +} + +fn edit_body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> bool { + let Some((original, a)) = app.wizard.editing.as_mut() else { return false }; + ui.label(egui::RichText::new(if original.is_some() { "Edit Action" } else { "New Action" }).font(theme::semibold(18.0))); + ui.add_space(8.0); + egui::Grid::new("action-edit").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label("Action name:"); + ui.add(egui::TextEdit::singleline(&mut a.name).desired_width(320.0).id_salt("action-name")); + ui.end_row(); + ui.label("Description:"); + ui.add(egui::TextEdit::singleline(&mut a.description).desired_width(320.0).id_salt("action-description")); + ui.end_row(); + }); + ui.add_space(6.0); + ui.label(egui::RichText::new("Steps").font(theme::semibold(13.0))); + let mut remove = None; + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + if a.steps.is_empty() { + ui.label(egui::RichText::new("Add the steps this action runs, in order.").color(t.text_muted)); + } + for (i, s) in a.steps.iter_mut().enumerate() { + ui.push_id(i, |ui| { + ui.horizontal(|ui| { + ui.label(format!("{}. {}", i + 1, s.label())); + if let Some(arg) = s.arg_mut() { + ui.add(egui::TextEdit::singleline(arg).desired_width(220.0)); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("Remove").clicked() { + remove = Some(i); + } + }); + }); + }); + } + }); + if let Some(i) = remove { + a.steps.remove(i); + } + let all = Step::all(); + let add = &mut app.wizard.add; + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("action-add-step").selected_text(all[(*add).min(all.len() - 1)].label()).width(240.0).show_ui(ui, |ui| { + for (i, s) in all.iter().enumerate() { + ui.selectable_value(add, i, s.label()); + } + }); + if ui.button("Add Step").clicked() { + a.steps.push(all[(*add).min(all.len() - 1)].clone()); + } + }); + ui.add_space(10.0); + let name = a.name.trim().to_string(); + let taken = + builtin().iter().any(|b| b.name == name) || app.custom_actions.iter().any(|c| c.name == name && original.as_deref() != Some(name.as_str())); + if taken { + ui.label(egui::RichText::new("An action with this name already exists.").small().color(t.text_muted)); + } + let (mut done, mut cancel) = (false, false); + let ok = !name.is_empty() && !taken && !a.steps.is_empty(); + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "Save", true)).inner.clicked() { + done = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + cancel = true; + } + }); + }); + if cancel { + app.wizard.editing = None; + } + if done && let Some((original, mut a)) = app.wizard.editing.take() { + a.name = name.clone(); + match original.and_then(|o| app.custom_actions.iter().position(|c| c.name == o)) { + Some(i) => app.custom_actions[i] = a, + None => app.custom_actions.push(a), + } + app.wizard.selected = Some(name); + } + false +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index c6b31161..22a550ca 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -374,6 +374,15 @@ impl PrintCraftApp { } "form.detect" => self.detect_fields(), "doc.compare" => self.dialog = Some(Dialog::CompareFiles), + "actions.wizard" | "actions.distribution" | "actions.optimize_scans" => { + self.wizard.editing = None; + match id { + "actions.distribution" => self.wizard.selected = Some("Prepare for Distribution".into()), + "actions.optimize_scans" => self.wizard.selected = Some("Optimize Scanned Documents".into()), + _ => {} + } + self.dialog = Some(Dialog::ActionWizard); + } "form.field.properties" => { if let Some((name, w)) = active.and_then(|i| self.views[i].prepare.selected.clone()) { self.open_field_props(&name, w); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e3df911a..4de0752b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -820,6 +820,11 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { close = go || cancel; return; } + Dialog::ActionWizard => { + ui.set_width(620.0); + close = crate::actions_ui::body(ui, app, &t); + return; + } Dialog::CompareFiles => { let (go, cancel) = crate::compare_ui::body(ui, app, &t); compare_now = go; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7780e8ec..e7538d46 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ //! `printcraft-engine`. mod a11y_ui; +mod actions_ui; pub mod canvas; mod chrome; mod combine_ui; @@ -199,6 +200,8 @@ pub enum Dialog { Preferences, /// Compare files: choose the older version. CompareFiles, + /// Action Wizard. + ActionWizard, /// Combine files: the files, their order and pages. Combine, /// Custom stamps ▸ Create. @@ -313,7 +316,14 @@ pub struct PrintCraftApp { pub ocr_draft: ocr_ui::OcrDraft, pub ocr_run: Option, pub ocr_batch: Option>>, - pub ocr_sync: bool, + /// Background jobs (OCR, actions) run inline instead (tests). + pub run_inline: bool, + /// Action Wizard: the user's actions, the dialog state, the running action and (tests) the + /// files to use instead of a picker. + pub custom_actions: Vec, + pub wizard: actions_ui::Wizard, + pub action_run: Option>>, + pub action_files_override: Option>, /// Compare files: the chosen older document and the last result. pub compare_old: Option, pub compare: Option, @@ -458,7 +468,11 @@ impl PrintCraftApp { ocr_draft: ocr_ui::OcrDraft::default(), ocr_run: None, ocr_batch: None, - ocr_sync: false, + run_inline: false, + custom_actions: Vec::new(), + wizard: Default::default(), + action_run: None, + action_files_override: None, compare_old: None, compare: None, js_console: Default::default(), @@ -801,6 +815,7 @@ impl PrintCraftApp { "trusted": trusted, "custom_stamps": stamps_ui::encode(&self.custom_stamps), "javascript": self.session.javascript(), + "actions": actions_ui::encode(&self.custom_actions), }) .to_string() } @@ -832,6 +847,7 @@ impl PrintCraftApp { self.digital_ids = ids; } self.custom_stamps = stamps_ui::decode(&v["custom_stamps"]); + self.custom_actions = actions_ui::decode(&v["actions"]); if let Some(on) = v["javascript"].as_bool() { self.session.set_javascript(on); } @@ -910,6 +926,7 @@ impl PrintCraftApp { "document-js" => Some(Dialog::DocumentJs), "preferences" => Some(Dialog::Preferences), "compare-files" => Some(Dialog::CompareFiles), + "action-wizard" => Some(Dialog::ActionWizard), "signature" => Some(Dialog::Signature), "optimize" => Some(Dialog::Optimize), "sign" | "certify" => { @@ -1092,6 +1109,7 @@ impl eframe::App for PrintCraftApp { self.process_pending_edits(); self.poll_export(); self.poll_ocr(); + self.poll_action(); self.process_file_requests(); // Pull finished renders into textures for every open document. for view in &mut self.views { diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index b21e7088..0e0286a4 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -170,7 +170,7 @@ impl PrintCraftApp { } }; #[cfg(not(target_arch = "wasm32"))] - if self.ocr_sync { + if self.run_inline { work(); } else { std::thread::Builder::new().name("printcraft-ocr".into()).spawn(work).ok(); @@ -235,7 +235,7 @@ impl PrintCraftApp { } }; #[cfg(not(target_arch = "wasm32"))] - if self.ocr_sync { + if self.run_inline { work(); } else { std::thread::Builder::new().name("printcraft-ocr-files".into()).spawn(work).ok(); diff --git a/crates/ui-egui/tests/actions.rs b/crates/ui-egui/tests/actions.rs new file mode 100644 index 00000000..ae30235d --- /dev/null +++ b/crates/ui-egui/tests/actions.rs @@ -0,0 +1,57 @@ +//! Action Wizard in the real shell (egui_kittest): run a built-in action on files, create an +//! action, and remember it. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_engine::Session; +use printcraft_engine::actions::Step; +use printcraft_ui_egui::PrintCraftApp; + +#[test] +fn run_and_create_actions() { + let dir = std::env::temp_dir().join(format!("printcraft-actions-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("out")).unwrap(); + let src = Session::new().create_from_text("t", "Draft report").unwrap(); + std::fs::write(dir.join("r.pdf"), &*src).unwrap(); + let d = dir.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PrintCraftApp::new(); + app.run_inline = true; + app.export_dir_override = Some(d.join("out").to_string_lossy().into_owned()); + app.action_files_override = Some(vec![d.join("r.pdf").to_string_lossy().into_owned()]); + app + }); + h.run_steps(2); + assert!(h.state_mut().execute("actions.distribution")); + h.run_steps(2); + h.get_by_label("Action Wizard"); + h.get_by_label("1. Remove hidden information"); + h.get_by_label("Start").click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Prepare for Distribution: 1 file done"); + assert!(std::fs::read(dir.join("out/r.pdf")).unwrap().starts_with(b"%PDF")); + + // A new action of our own. + assert!(h.state_mut().execute("actions.wizard")); + h.run_steps(2); + h.get_by_label("New Action…").click(); + h.run_steps(2); + { + let (_, a) = h.state_mut().wizard.editing.as_mut().unwrap(); + a.name = "Stamp drafts".into(); + a.steps.push(Step::AddWatermark("DRAFT".into())); + } + h.run_steps(3); + h.get_by_label("Save").click(); + h.run_steps(3); + assert_eq!(h.state().custom_actions.len(), 1); + h.get_by_label("1. Add watermark: DRAFT"); + let saved = h.state().persist(); + let mut fresh = PrintCraftApp::new(); + fresh.restore(&saved); + assert_eq!(fresh.custom_actions, h.state().custom_actions); + h.get_by_label("Start").click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Stamp drafts: 1 file done"); +} diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs index bb3ee396..62d251ee 100644 --- a/crates/ui-egui/tests/ocr.rs +++ b/crates/ui-egui/tests/ocr.rs @@ -20,7 +20,7 @@ fn recognize_text_dialog_adds_searchable_text() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PrintCraftApp::new(); app.open_bytes("scan.pdf", None, scan()).unwrap(); - app.ocr_sync = true; + app.run_inline = true; app }); h.run_steps(4); @@ -52,7 +52,7 @@ fn recognize_text_in_multiple_files_writes_searchable_copies() { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let mut app = PrintCraftApp::new(); - app.ocr_sync = true; + app.run_inline = true; app.export_dir_override = Some(dir.to_string_lossy().into_owned()); app.use_files(printcraft_ui_egui::FilePurpose::Ocr, vec![("one.pdf".into(), scan()), ("two.pdf".into(), scan())]); assert!(app.ocr_batch.is_none(), "finished"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 68f9dffd..87ccfb48 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9059,11 +9059,11 @@ title = "Run guided actions on files" area = "N" tier = "P1" milestone = "M13" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["actions.wizard"] +tools = ["action_list", "action_run"] +evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"] +notes = "Pick an action and files; results go to a chosen folder, in the background with progress." [[feature]] id = "misc.action-wizard-create" @@ -9071,11 +9071,11 @@ title = "Create and edit actions" area = "N" tier = "P1" milestone = "M13" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["actions.wizard"] +tools = ["action_run"] +evidence = ["crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"] +notes = "New/Edit/Delete actions with 12 step kinds (OCR, reduce size, remove hidden information, sanitize, flatten, watermark, header, footer, title, detect fields, JavaScript); remembered between runs." [[feature]] id = "misc.action-wizard-manage" @@ -9095,11 +9095,11 @@ title = "Built-in action: prepare for distribution" area = "N" tier = "P1" milestone = "M13" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["actions.distribution"] +tools = ["action_run"] +evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"] +notes = "Remove hidden information, flatten comments and fields." [[feature]] id = "misc.action-optimize-scans" @@ -9107,11 +9107,11 @@ title = "Built-in action: optimize scanned documents" area = "N" tier = "P1" milestone = "M13" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["actions.optimize_scans"] +tools = ["action_run"] +evidence = ["crates/engine/src/tests.rs::actions_run_their_steps_on_files", "crates/automation/tests/automation.rs::actions_through_tools"] +notes = "Recognize text, then reduce file size." [[feature]] id = "misc.action-archive" @@ -9143,11 +9143,11 @@ title = "Batch processing over files and folders" area = "N" tier = "P1" milestone = "M13" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["actions.wizard", "ocr.recognize_batch"] +tools = ["action_run", "ocr_recognize_files"] +evidence = ["crates/automation/tests/automation.rs::actions_through_tools", "crates/ui-egui/tests/actions.rs::run_and_create_actions"] +notes = "Actions and OCR run over chosen files; whole folders (with subfolders) not yet." [[feature]] id = "misc.action-prompt-steps"