M13: Action Wizard: built-in actions (Prepare for Distribution, Optimize Scanned Documents, page numbers, confidential, sanitize), custom actions with 12 step kinds, background runs over files; action_list, action_run tools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a4bf717c06
commit
632a21bfcf
|
|
@ -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<Value> {
|
||||
use printcraft_engine::actions::{Step, builtin};
|
||||
let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() });
|
||||
let actions: Vec<Value> = builtin()
|
||||
.iter()
|
||||
.map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::<Vec<_>>() }))
|
||||
.collect();
|
||||
let steps: Vec<Value> = 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<Value> {
|
||||
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::<Result<Vec<_>>>()?;
|
||||
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")?;
|
||||
|
|
|
|||
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -759,6 +759,21 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
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")
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `<<Page 1 of n>>` 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<Step> {
|
||||
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<Step> {
|
||||
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<Step>,
|
||||
/// One of PrintCraft's own actions (can't be edited or deleted).
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
/// The built-in actions.
|
||||
pub fn builtin() -> Vec<Action> {
|
||||
let a = |name: &str, description: &str, steps: Vec<Step>| 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 <<n>>".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<Vec<u8>>,
|
||||
/// One line per step.
|
||||
pub log: Vec<String>,
|
||||
}
|
||||
|
||||
fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec<String>) -> Result<Option<DocId>, EditError> {
|
||||
let pages = s.get(id).map_or(0, |d| d.info.pages.len());
|
||||
let all: Vec<usize> = (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::<usize>()));
|
||||
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<Vec<u8>>, mut progress: impl FnMut(usize, usize)) -> Result<FileResult, String> {
|
||||
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 })
|
||||
}
|
||||
|
|
@ -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")),
|
||||
],
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// The action being created or edited (and the name it had).
|
||||
pub editing: Option<(Option<String>, 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<String>,
|
||||
}
|
||||
|
||||
impl PrintCraftApp {
|
||||
/// Built-in actions, then the user's.
|
||||
pub fn all_actions(&self) -> Vec<Action> {
|
||||
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<u8>)>) {
|
||||
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<serde_json::Value> = 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<Action> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ocr_ui::OcrRun>,
|
||||
pub ocr_batch: Option<std::sync::Arc<std::sync::Mutex<ocr_ui::BatchProgress>>>,
|
||||
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<printcraft_engine::actions::Action>,
|
||||
pub wizard: actions_ui::Wizard,
|
||||
pub action_run: Option<std::sync::Arc<std::sync::Mutex<actions_ui::RunProgress>>>,
|
||||
pub action_files_override: Option<Vec<String>>,
|
||||
/// Compare files: the chosen older document and the last result.
|
||||
pub compare_old: Option<DocId>,
|
||||
pub compare: Option<compare_ui::CompareState>,
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue