Merge pull request #1493

fix(cli): support --cmd-file for batch commands (#1098), carried from #1150
This commit is contained in:
Brandon Thomas committed 2026-10-30 15:58:53 -04:00
commit e279461ca7
4 files changed
+231 -10

No files matched your search

+2
View File
@@ -147,6 +147,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
| 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) |
| 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) |
| 2026-10-28 | CLI `run --cmd-file FILE` reads UTF-8 commands in order alongside `--cmd`, skips blank/comment lines, and reports file/line errors before final save/export (#1098) |
| 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
| 2026-10-29 | Picture Format: the Picture Styles row shows as many styles as leave room for the groups after it, and its arrow opens all 24. With every style in the row, Crop and Size sat past the ribbon's edge in any window narrower than about 2500 px, so Crop could not be seen without scrolling the ribbon (#1232). In the default 1440 px window Crop, Height and Width are now in view, and from 1600 px the whole tab fits |
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
@@ -309,6 +310,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
| 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged |
| 2026-10-28 | trivial | Progress log: CLI command-file input (#1098). Scores and milestone scope unchanged. |
| 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
+10 -1
View File
@@ -58,7 +58,7 @@ animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
},
Subcommand {
name: "run",
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS] | --cmd-file FILE]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
summary: "run commands on a presentation, then save or export it",
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
@@ -71,6 +71,15 @@ slide). --export-params passes file.export options as one JSON object, such as
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
--cmd-file FILE reads the same commands from a UTF-8 text file, one per line, avoiding shell quoting
and command-line length limits. Each line can be up to 16 MiB, including commands with embedded
media data. Blank lines and full-line # comments are ignored, as is surrounding whitespace. CRLF
line endings and an optional UTF-8 BOM at the start of the file are accepted.
Repeat --cmd-file or mix it with --cmd: commands execute in the order those options appear.
File errors name the path; line-reading, JSON and command errors also name the line number.
deckcraft-cli run --cmd-file commands.txt --cmd slide.next --save result.deckcraft
deckcraft-cli run \\
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
+69 -9
View File
@@ -465,6 +465,69 @@ fn output_format(path: &str) -> Result<&'static str, String> {
})
}
enum CommandSource {
Inline(String),
File(String),
}
const MAX_COMMAND_FILE_LINE_BYTES: usize = 16 * 1024 * 1024;
fn read_command_file_line(reader: &mut impl std::io::BufRead, line: &mut Vec<u8>) -> std::io::Result<bool> {
line.clear();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(!line.is_empty());
}
let newline = available.iter().position(|byte| *byte == b'\n');
let count = newline.unwrap_or(available.len());
line.len().checked_add(count).filter(|length| *length <= MAX_COMMAND_FILE_LINE_BYTES).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("command line exceeds {MAX_COMMAND_FILE_LINE_BYTES} byte limit"))
})?;
line.extend_from_slice(&available[..count]);
reader.consume(count + usize::from(newline.is_some()));
if newline.is_some() {
return Ok(true);
}
}
}
/// The same command syntax and execution path for --cmd and each command-file line.
fn run_command(s: &mut Session, command: &str, print: bool) -> Result<(), String> {
let (id, params) = match command.split_once('=') {
Some((id, j)) => (id, serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
None => (command, json!({})),
};
let r = s.execute(id, &params).map_err(|e| e.to_string())?;
if print {
outln!("{id} → {r}");
}
Ok(())
}
fn run_command_file(s: &mut Session, path: &str, print: bool) -> Result<(), String> {
use std::io::BufReader;
let file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let mut reader = BufReader::new(file);
let mut bytes = Vec::new();
let mut line_number = 0usize;
loop {
line_number = line_number.checked_add(1).ok_or_else(|| format!("{path}: too many lines"))?;
if !read_command_file_line(&mut reader, &mut bytes).map_err(|e| format!("{path}:{line_number}: {e}"))? {
break;
}
let line = std::str::from_utf8(&bytes).map_err(|e| format!("{path}:{line_number}: invalid UTF-8: {e}"))?;
let line = if line_number == 1 { line.trim_start_matches('\u{feff}') } else { line };
let command = line.trim();
if command.is_empty() || command.starts_with('#') {
continue;
}
run_command(s, command, print).map_err(|e| format!("{path}:{line_number}: {e}"))?;
}
Ok(())
}
fn run(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut sample = false;
@@ -478,7 +541,8 @@ fn run(args: &[String]) -> Result<(), String> {
match a.as_str() {
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
"--sample" => sample = true,
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
"--cmd" => cmds.push(CommandSource::Inline(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?)),
"--cmd-file" => cmds.push(CommandSource::File(it.next().cloned().ok_or("--cmd-file needs a file")?)),
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
"--export-params" => {
@@ -501,14 +565,10 @@ fn run(args: &[String]) -> Result<(), String> {
}
let mut s = Session::new();
open(&mut s, input.as_deref(), sample)?;
for c in cmds {
let (id, p) = match c.split_once('=') {
Some((id, j)) => (id.to_string(), serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
None => (c.clone(), json!({})),
};
let r = s.execute(&id, &p).map_err(|e| e.to_string())?;
if print {
outln!("{id} → {r}");
for source in cmds {
match source {
CommandSource::Inline(command) => run_command(&mut s, &command, print)?,
CommandSource::File(path) => run_command_file(&mut s, &path, print)?,
}
}
if let Some(p) = save {
+150
View File
@@ -20,6 +20,153 @@ fn words(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn command_file_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-cli-command-file-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn inspected_titles(out: &str) -> Vec<String> {
let answer = out.lines().last().unwrap().strip_prefix("document.inspect → ").unwrap();
let doc: serde_json::Value = serde_json::from_str(answer).unwrap();
doc["slides"].as_array().unwrap().iter().map(|s| s["title"].as_str().unwrap().to_string()).collect()
}
#[test]
fn command_file_runs_utf8_lines_in_order_and_skips_comments() {
let dir = command_file_dir("lines");
let file = dir.join("commands with spaces.txt");
std::fs::write(
&file,
"\u{feff}# heading\r\n\r\n # indented comment\r\n slide.new={\"title\":\"Résumé = # 测试\"} \r\nslide.new\r\nslide.new={\"title\":\"Last\"}",
)
.unwrap();
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy(), "--cmd", "document.inspect"]);
let out = stdout(&o);
let commands: Vec<_> = out.lines().map(|l| l.split_once(" → ").unwrap().0).collect();
assert_eq!(commands, ["slide.new", "slide.new", "slide.new", "document.inspect"]);
let titles = inspected_titles(&out);
assert_eq!(titles.len(), 4);
assert_eq!(titles[1], "Résumé = # 测试");
assert_eq!(titles[3], "Last");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn command_files_and_inline_commands_keep_argument_order() {
let dir = command_file_dir("mixed");
let first = dir.join("first.txt");
let second = dir.join("second.txt");
let saved = dir.join("result.deckcraft");
std::fs::write(&first, "slide.new={\"title\":\"First file\"}\n").unwrap();
std::fs::write(&second, "slide.new={\"title\":\"Second file\"}\n").unwrap();
let o = cli(&[
"run",
"--cmd",
"slide.new={\"title\":\"Inline first\"}",
"--cmd-file",
&first.to_string_lossy(),
"--cmd",
"slide.new={\"title\":\"Inline middle\"}",
"--cmd-file",
&second.to_string_lossy(),
"--cmd",
"document.inspect",
"--print",
"--save",
&saved.to_string_lossy(),
]);
let out = stdout(&o);
let titles = inspected_titles(&out);
assert_eq!(&titles[1..], ["Inline first", "First file", "Inline middle", "Second file"]);
assert_eq!(out.lines().count(), 5);
let reopened = cli(&["run", "--in", &saved.to_string_lossy(), "--cmd", "document.inspect", "--print"]);
assert_eq!(inspected_titles(&stdout(&reopened)), titles);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_failed_file_command_stops_later_commands_and_final_outputs() {
let dir = command_file_dir("failure");
let file = dir.join("commands.txt");
let later = dir.join("later-in-file.deckcraft");
let inline = dir.join("later-inline.deckcraft");
let save = dir.join("saved.deckcraft");
let export = dir.join("exported.png");
let inline_command = format!("file.saveAs={}", serde_json::json!({"path": inline.to_string_lossy()}));
for (command, message) in [("slide.new={", "bad JSON"), ("no.such.command", "unknown command"), ("slide.go={\"index\":999}", "slide")] {
let later_command = format!("file.saveAs={}", serde_json::json!({"path": later.to_string_lossy()}));
std::fs::write(&file, format!("# header\nslide.new\n{command}\n{later_command}\n")).unwrap();
std::fs::write(&save, b"keep saved file").unwrap();
std::fs::write(&export, b"keep exported file").unwrap();
let o = cli(&[
"run",
"--print",
"--cmd-file",
&file.to_string_lossy(),
"--cmd",
&inline_command,
"--save",
&save.to_string_lossy(),
"--export",
&export.to_string_lossy(),
]);
let err = stderr(&o);
assert!(err.contains(&format!("{}:3:", file.display())) && err.contains(message), "{err}");
assert_eq!(String::from_utf8_lossy(&o.stdout).lines().count(), 1, "only the first command ran");
assert!(!later.exists() && !inline.exists(), "commands after the failure must not run");
assert_eq!(std::fs::read(&save).unwrap(), b"keep saved file");
assert_eq!(std::fs::read(&export).unwrap(), b"keep exported file");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn command_file_read_errors_name_the_path_and_utf8_line() {
let dir = command_file_dir("read-errors");
let missing = dir.join("missing.txt");
let invalid = dir.join("invalid.txt");
let save = dir.join("out.deckcraft");
std::fs::write(&invalid, b"slide.new\n\xff\n").unwrap();
for file in [&missing, &dir, &invalid] {
let o = cli(&["run", "--cmd-file", &file.to_string_lossy(), "--save", &save.to_string_lossy()]);
let err = stderr(&o);
assert!(err.contains(&file.display().to_string()), "{err}");
if file == &invalid {
assert!(err.contains(&format!("{}:2:", invalid.display())) && err.contains("UTF-8"), "{err}");
}
assert!(!save.exists());
}
assert!(stderr(&cli(&["run", "--cmd-file"])).contains("--cmd-file needs a file"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn command_file_rejects_an_oversized_line_before_running_it() {
let dir = command_file_dir("line-limit");
let file = dir.join("commands.txt");
let command = format!("slide.new={{\"title\":\"{}\"}}", "x".repeat(16 * 1024 * 1024));
std::fs::write(&file, format!("{command}\nslide.new\n")).unwrap();
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy()]);
let err = stderr(&o);
assert!(err.contains(&format!("{}:1:", file.display())) && err.contains("command line exceeds 16777216 byte limit"), "{err}");
assert!(o.stdout.is_empty(), "the oversized command must not run");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn inline_commands_keep_their_existing_syntax_and_errors() {
let o = cli(&["run", "--cmd", "slide.new", "--cmd", "slide.new={\"title\":\"Inline = #\"}", "--cmd", "document.inspect", "--print"]);
let out = stdout(&o);
let titles = inspected_titles(&out);
assert_eq!(titles.len(), 3);
assert_eq!(titles[2], "Inline = #");
assert!(stderr(&cli(&["run", "--cmd", "slide.new={"])).starts_with("deckcraft-cli: slide.new: bad JSON:"));
assert!(stderr(&cli(&["run", "--cmd", "no.such.command"])).contains("unknown command `no.such.command`"));
}
#[test]
fn an_unknown_subcommand_is_named_before_the_overview() {
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
@@ -74,6 +221,9 @@ fn run_help_says_what_the_ids_do_not() {
"Shape ids are looked up on the current slide only.",
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
"format.* acts on the selected text or shapes",
"--cmd-file FILE reads the same commands from a UTF-8 text file, one per line",
"commands execute in the order those options appear.",
"If a command fails, run stops there and saves nothing.",
] {
assert!(out.contains(fact), "{fact}: {out}");
}